mirror of
https://github.com/block/goose.git
synced 2026-07-09 16:09:22 +00:00
chore: added a recipe to help identify high risk change prs for testing (#7651)
This commit is contained in:
parent
ade0839804
commit
2f49bad19b
4 changed files with 588 additions and 14 deletions
|
|
@ -2,14 +2,25 @@
|
|||
|
||||
## Version: {{VERSION}}
|
||||
|
||||
### Identify the high risk changes in this Release
|
||||
|
||||
```
|
||||
./workflow_recipes/release_risk_check/run.sh {{VERSION}}
|
||||
```
|
||||
|
||||
It will generate an analysis report in `/tmp/release_report_final.md` and perform testing is necessary for high risk pr changes.
|
||||
|
||||
|
||||
## Regression Testing
|
||||
|
||||
Make a copy of this document for each version and check off as steps are verified.
|
||||
|
||||
## Provider Testing
|
||||
### Provider Testing
|
||||
|
||||
- [ ] Run `./scripts/test_providers.sh` locally from the release branch and verify all providers/models work
|
||||
- [ ] Launch goose, click reset providers, choose databricks and a model
|
||||
|
||||
## Starting Conversations
|
||||
### Starting Conversations
|
||||
|
||||
Test various ways to start a conversation:
|
||||
|
||||
|
|
@ -26,9 +37,9 @@ Test various ways to start a conversation:
|
|||
- [ ] Open a new window, click chat in left side for new chat
|
||||
- [ ] Click "create a tamagotchi game" in popular chat topics to test developer extension
|
||||
|
||||
## Recipes
|
||||
### Recipes
|
||||
|
||||
### Create Recipe from Session
|
||||
#### Create Recipe from Session
|
||||
|
||||
- [ ] Start a simple chat conversation like "hi"
|
||||
- [ ] Click "create a recipe from this session" in the bottom chat bar
|
||||
|
|
@ -37,7 +48,7 @@ Test various ways to start a conversation:
|
|||
- [ ] Can launch create and run recipe - launches in a new window showing as a recipe agent chat with parameters filled in and interact with it
|
||||
- [ ] Recipe should be saved in recipe library
|
||||
|
||||
### Use Existing Recipe
|
||||
#### Use Existing Recipe
|
||||
|
||||
- [ ] Pick trip planner from recipe hub (go/gooserecipes)
|
||||
- [ ] See the warning whether to trust this recipe (only on fresh install)
|
||||
|
|
@ -46,7 +57,7 @@ Test various ways to start a conversation:
|
|||
- [ ] Check results are reasonable
|
||||
- [ ] Ask how many days the trip is for - should say 14
|
||||
|
||||
### Recipe Management
|
||||
#### Recipe Management
|
||||
|
||||
- [ ] Go to recipe manager and enter a new recipe to generate a joke
|
||||
- [ ] See that it works if you run it
|
||||
|
|
@ -56,7 +67,7 @@ Test various ways to start a conversation:
|
|||
- [ ] Delete the recipe from the recipe manager
|
||||
- [ ] Verify recipe is actually deleted
|
||||
|
||||
### Recipe from File
|
||||
#### Recipe from File
|
||||
|
||||
- [ ] Create a file `~/.config/goose/recipes/test-recipe.yaml` with the following content:
|
||||
|
||||
|
|
@ -79,40 +90,40 @@ recipe:
|
|||
- [ ] Go to hub and enter "what is the value of test_param"
|
||||
- [ ] See a new chat that says it has no idea (recipe is no longer active)
|
||||
|
||||
## Extensions
|
||||
### Extensions
|
||||
|
||||
### Manual Extension Addition
|
||||
#### Manual Extension Addition
|
||||
|
||||
- [ ] Can manually add an extension using random quotes from project
|
||||
- [ ] Add new custom stdio extension with the following command and save:
|
||||
- [ ] `node /ABSOLUTE/PATH/TO/goose/ui/desktop/tests/e2e/basic-mcp.ts` (use your actual project path)
|
||||
- [ ] Should add and can chat to ask for a random quote
|
||||
|
||||
### Playwright Extension
|
||||
#### Playwright Extension
|
||||
|
||||
- [ ] Install the playwright extension from the extensions hub
|
||||
- [ ] Tell it to open a browser and search on Google for cats
|
||||
- [ ] Verify that the browser opens and navigates
|
||||
|
||||
### Extension with Environment Variables
|
||||
#### Extension with Environment Variables
|
||||
|
||||
- [ ] Install an extension from deeplink that needs env variables:
|
||||
- [ ] Use: `goose://extension?cmd=npx&arg=-y&arg=%40upstash%2Fcontext7-mcp&id=context7&name=Context7&description=Use%20up-to-date%20code%20and%20docs&env=TEST_ACCESS_TOKEN`
|
||||
- [ ] Extension page should load with env variables modal showing
|
||||
- [ ] Allow form input and saving extension
|
||||
|
||||
## Speech-to-Text (Local Model)
|
||||
### Speech-to-Text (Local Model)
|
||||
|
||||
- [ ] Go to Settings > Chat > Voice dictation provider and select the small model
|
||||
- [ ] Run a quick test that speech-to-text is working (click the mic button, speak, verify transcription)
|
||||
- [ ] Also try OpenAI using your OpenAI key
|
||||
|
||||
## Settings
|
||||
### Settings
|
||||
|
||||
- [ ] Settings page loads and all tabs load
|
||||
- [ ] Can change dark mode setting
|
||||
|
||||
## Follow-up Issues
|
||||
### Follow-up Issues
|
||||
|
||||
Link any GitHub issues filed during testing:
|
||||
|
||||
|
|
|
|||
116
workflow_recipes/release_risk_check/recipe.yaml
Normal file
116
workflow_recipes/release_risk_check/recipe.yaml
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
version: 1.0.0
|
||||
title: "Release Change Risk Check"
|
||||
description: "Create a report to access the change in an upcoming release"
|
||||
|
||||
instructions: |
|
||||
## Step 1: Generate the heuristic report
|
||||
|
||||
Run the script to collect PR data and do initial risk scoring:
|
||||
|
||||
{{recipe_dir}}/release_risk_report.py --version {{version}} -o /tmp/release_report.md
|
||||
|
||||
This produces a report with each PR classified as HIGH/MEDIUM/LOW based on file changes, lines of code, and core path analysis.
|
||||
|
||||
## Step 2: AI review of MEDIUM and HIGH risk PRs
|
||||
|
||||
Take the MEDIUM and HIGH risk PRs from the Step 1 report and feed them to an LLM with the following prompt:
|
||||
|
||||
---
|
||||
|
||||
You are a release risk assessor for **Goose**, an open-source AI-powered CLI coding agent built in Rust with a React/Electron desktop UI.
|
||||
|
||||
### Architecture (most sensitive areas first)
|
||||
|
||||
**CRITICAL — changes here can bypass security or cause data loss:**
|
||||
- **Permission system** (`crates/goose/src/permission/`) — controls what the agent is allowed to do. Permission bypass = agent has unrestricted access to user's machine.
|
||||
- **Tool execution pipeline** (`crates/goose/src/agents/tool_execution.rs`, `agent.rs`) — dispatches shell commands, file edits, etc. Bugs here can cause uncontrolled execution.
|
||||
- **Security inspection** (`crates/goose/src/tool_inspection.rs`) — detects prompt injection and destructive operations. Disabling or weakening = injection attacks succeed.
|
||||
- **Server action approval** (`crates/goose-server/src/routes/action_required.rs`) — user approval API. If broken, agent executes without user consent.
|
||||
- **Session database** (`crates/goose/src/session/session_manager.rs`) — stores all conversations in SQLite. Schema changes risk data loss.
|
||||
- **Authentication** (`crates/goose-server/src/auth.rs`) — access control for the HTTP server.
|
||||
|
||||
**HIGH — changes here affect core functionality:**
|
||||
- **Agent loop** (`crates/goose/src/agents/`) — message routing, turn limits, conversation compaction.
|
||||
- **Provider integrations** (`crates/goose/src/providers/`) — LLM API calls, credential handling, cost tracking, response parsing.
|
||||
- **Extension manager** (`crates/goose/src/agents/extension_manager.rs`) — loads MCP extensions, tool discovery. Malicious extensions could be loaded.
|
||||
- **Server routes** (`crates/goose-server/src/routes/`) — HTTP API that the desktop UI and CLI talk to.
|
||||
|
||||
**MEDIUM — changes here affect specific features:**
|
||||
- **CLI commands** (`crates/goose-cli/`) — argument parsing, session management, recipe execution.
|
||||
- **Desktop UI** (`ui/desktop/src/`) — React components, state management, settings.
|
||||
- **Platform extensions** (`crates/goose/src/agents/platform_extensions/`) — built-in tools like shell, file edit.
|
||||
|
||||
### Risk levels — assign ONE per PR:
|
||||
|
||||
- **HIGH**: Change could cause security bypass, data loss, crashes affecting all users, or break core agent functionality. Examples: modifying permission checks, changing tool execution flow, altering session schema, touching auth logic.
|
||||
- **MEDIUM**: Change could cause issues in specific scenarios but not for all users. Examples: provider-specific bug, UI regression, new feature with limited blast radius, config changes.
|
||||
- **LOW**: Very unlikely to cause issues. Examples: small isolated fix with tests, additive-only new feature in non-core area, UI cosmetic change, test-only changes.
|
||||
|
||||
### Signals that INCREASE risk:
|
||||
- Modifies existing logic in critical/high areas (vs adding new code)
|
||||
- No testing section in PR description
|
||||
- No approvers or only bot approvers
|
||||
- Large diff touching many files across different subsystems
|
||||
- Reverts or re-applies of previous changes (indicates instability)
|
||||
- Touches error handling or fallback paths (silent failures)
|
||||
|
||||
### Signals that DECREASE risk:
|
||||
- Has thorough testing section with specific test cases mentioned
|
||||
- Change is purely additive (new files, new feature behind flag)
|
||||
- Only touches test files or snapshots
|
||||
- Small, focused diff in one subsystem
|
||||
|
||||
### Task
|
||||
|
||||
For each PR below:
|
||||
|
||||
1. **Assess risk** — assign HIGH / MEDIUM / LOW with reasoning
|
||||
2. **Testing confidence** — check if the PR has a testing section. If yes, summarise what was tested in one sentence. If no, say "No testing section".
|
||||
3. **Suggest testing steps** — for PRs you rate HIGH or MEDIUM, provide 2-4 concrete test steps
|
||||
|
||||
Respond in this format for each PR:
|
||||
|
||||
| PR | Heuristic | AI Risk | Reasoning | Concern | Testing |
|
||||
|----|-----------|---------|-----------|---------|---------|
|
||||
|
||||
Where:
|
||||
- **Heuristic** = the score from Step 1 (HIGH or MEDIUM)
|
||||
- **AI Risk** = your assessment (HIGH / MEDIUM / LOW)
|
||||
- **Reasoning** = 1-2 sentences explaining why
|
||||
- **Concern** = specific thing to watch for during release, or "None"
|
||||
- **Testing** = summary of PR's testing section, or "No testing section"
|
||||
|
||||
If your AI Risk differs from the Heuristic, bold it to highlight the disagreement.
|
||||
|
||||
Then, for each PR you rated HIGH or MEDIUM, list suggested testing steps below the table. Use this guide:
|
||||
|
||||
### PRs to review:
|
||||
|
||||
<PASTE MEDIUM AND HIGH RISK PRS FROM STEP 1 REPORT HERE>
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Generate the final report
|
||||
|
||||
Combine the outputs from Step 1 and Step 2 into a final report:
|
||||
|
||||
1. Start with the Step 1 report header (repo, total PRs, risk summary)
|
||||
2. Update the risk summary counts based on AI-revised risk levels
|
||||
3. For each MEDIUM/HIGH PR, append the AI assessment:
|
||||
- `AI assessment: [LEVEL] — reasoning`
|
||||
- `AI concern: concern text`
|
||||
- If AI disagreed with heuristic, note: `(downgraded from HIGH)` or `(upgraded from MEDIUM)`
|
||||
4. LOW risk PRs and skipped PRs remain unchanged from Step 1
|
||||
5. Add a summary section at the top listing the top concerns across all HIGH risk PRs
|
||||
|
||||
prompt: follow the instructions to generate the final report
|
||||
|
||||
parameters:
|
||||
- key: "version"
|
||||
input_type: string
|
||||
requirement: required
|
||||
description: "release version"
|
||||
|
||||
extensions:
|
||||
- type: platform
|
||||
name: developer
|
||||
435
workflow_recipes/release_risk_check/release_risk_report.py
Executable file
435
workflow_recipes/release_risk_check/release_risk_report.py
Executable file
|
|
@ -0,0 +1,435 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a release risk assessment report for a Goose release PR.
|
||||
|
||||
Usage:
|
||||
.release_risk_report.py --version 1.27.0
|
||||
.release_risk_report.py --version 1.27.0 --output report.md
|
||||
.release_risk_report.py --version 1.27.0 --pr 7611
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
REPO = "block/goose"
|
||||
|
||||
# Paths considered documentation-only
|
||||
DOC_PATTERNS = [
|
||||
r"^documentation/",
|
||||
]
|
||||
|
||||
# Paths considered high-risk core code
|
||||
CORE_PATHS = [
|
||||
"crates/goose/src/agents/",
|
||||
"crates/goose/src/providers/",
|
||||
"crates/goose-server/",
|
||||
"crates/goose-cli/",
|
||||
"crates/goose/src/session",
|
||||
"crates/goose/src/permission",
|
||||
]
|
||||
|
||||
# Dependency lock files (safe to skip — lock files only, not manifests)
|
||||
DEP_LOCK_FILES = [
|
||||
"Cargo.lock",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
]
|
||||
|
||||
|
||||
def run_gh(args: list[str]) -> str:
|
||||
"""Run a gh CLI command and return stdout."""
|
||||
result = subprocess.run(
|
||||
["gh"] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" [warn] gh command failed: gh {' '.join(args)}", file=sys.stderr)
|
||||
print(f" {result.stderr.strip()}", file=sys.stderr)
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def find_release_pr(repo: str, version: str) -> int | None:
|
||||
"""Find the open release PR matching the given version string."""
|
||||
output = run_gh([
|
||||
"pr", "list", "--repo", repo, "--state", "open",
|
||||
"--json", "number,title", "--limit", "100",
|
||||
])
|
||||
if not output:
|
||||
return None
|
||||
prs = json.loads(output)
|
||||
for pr in prs:
|
||||
if version.lower() in pr["title"].lower():
|
||||
return pr["number"]
|
||||
return None
|
||||
|
||||
|
||||
def get_pr_body(repo: str, pr_number: int) -> str:
|
||||
"""Get the body of a PR."""
|
||||
return run_gh(["pr", "view", str(pr_number), "--repo", repo, "--json", "body", "--jq", ".body"])
|
||||
|
||||
|
||||
def extract_pr_numbers(body: str) -> list[int]:
|
||||
"""Extract PR numbers from the 'Changes in This Release' section."""
|
||||
section_match = re.search(r"## Changes in This Release\s*\n", body)
|
||||
if not section_match:
|
||||
print("[error] Could not find 'Changes in This Release' section", file=sys.stderr)
|
||||
return []
|
||||
|
||||
section_text = body[section_match.end():]
|
||||
end_match = re.search(r"\n---|\n##", section_text)
|
||||
if end_match:
|
||||
section_text = section_text[:end_match.start()]
|
||||
|
||||
pr_numbers = re.findall(r"\(#(\d+)\)", section_text)
|
||||
return [int(n) for n in pr_numbers]
|
||||
|
||||
|
||||
def is_doc_only(files: list[dict]) -> bool:
|
||||
"""Return True if all changed files are documentation/non-code."""
|
||||
if not files:
|
||||
return False
|
||||
for f in files:
|
||||
path = f.get("path", "")
|
||||
if not any(re.search(pat, path) for pat in DOC_PATTERNS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_deps_only(files: list[dict]) -> bool:
|
||||
"""Return True if all changed files are dependency/lock files."""
|
||||
if not files:
|
||||
return False
|
||||
for f in files:
|
||||
path = f.get("path", "")
|
||||
basename = path.rsplit("/", 1)[-1]
|
||||
if basename not in DEP_LOCK_FILES:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def assess_risk(files: list[dict]) -> dict:
|
||||
"""Assess risk level and return risk info dict."""
|
||||
total_additions = sum(f.get("additions", 0) for f in files)
|
||||
total_deletions = sum(f.get("deletions", 0) for f in files)
|
||||
total_lines = total_additions + total_deletions
|
||||
num_files = len(files)
|
||||
|
||||
paths = [f.get("path", "") for f in files]
|
||||
|
||||
# Classify files
|
||||
core_files = [p for p in paths if any(p.startswith(cp) for cp in CORE_PATHS)]
|
||||
test_files = [p for p in paths if "test" in p.lower() or "snap" in p.lower()]
|
||||
dep_files = [p for p in paths if p.rsplit("/", 1)[-1] in DEP_LOCK_FILES]
|
||||
prod_files = [p for p in paths if p not in test_files and p not in dep_files]
|
||||
|
||||
# Collect risk factors
|
||||
factors = []
|
||||
|
||||
if total_lines > 500:
|
||||
factors.append(f"Large change ({total_lines} lines)")
|
||||
elif total_lines > 200:
|
||||
factors.append(f"Medium-sized change ({total_lines} lines)")
|
||||
|
||||
if num_files > 10:
|
||||
factors.append(f"Touches {num_files} files")
|
||||
|
||||
if core_files:
|
||||
factors.append(f"Modifies core code: {', '.join(summarize_paths(core_files))}")
|
||||
|
||||
if dep_files:
|
||||
factors.append(f"Dependency changes: {', '.join(f.rsplit('/', 1)[-1] for f in dep_files)}")
|
||||
|
||||
if len(prod_files) > 0 and len(test_files) == 0:
|
||||
factors.append("No test files changed")
|
||||
|
||||
# Determine risk level
|
||||
risk_score = 0
|
||||
if total_lines > 500:
|
||||
risk_score += 2
|
||||
elif total_lines > 200:
|
||||
risk_score += 1
|
||||
if num_files > 10:
|
||||
risk_score += 1
|
||||
if core_files:
|
||||
risk_score += 2
|
||||
if len(prod_files) > 0 and len(test_files) == 0:
|
||||
risk_score += 1
|
||||
|
||||
if risk_score >= 4:
|
||||
level = "HIGH"
|
||||
elif risk_score >= 2:
|
||||
level = "MEDIUM"
|
||||
else:
|
||||
level = "LOW"
|
||||
|
||||
# Summarize key paths (deduplicate to directory level)
|
||||
key_paths = summarize_paths(paths)
|
||||
|
||||
return {
|
||||
"level": level,
|
||||
"score": risk_score,
|
||||
"factors": factors,
|
||||
"total_additions": total_additions,
|
||||
"total_deletions": total_deletions,
|
||||
"total_lines": total_lines,
|
||||
"num_files": num_files,
|
||||
"key_paths": key_paths,
|
||||
}
|
||||
|
||||
|
||||
def summarize_paths(paths: list[str], max_display: int = 5) -> list[str]:
|
||||
"""Summarize a list of file paths to their common directories."""
|
||||
dirs = set()
|
||||
for p in paths:
|
||||
parts = p.split("/")
|
||||
if len(parts) > 3:
|
||||
dirs.add("/".join(parts[:3]) + "/")
|
||||
else:
|
||||
dirs.add(p)
|
||||
result = sorted(dirs)
|
||||
if len(result) > max_display:
|
||||
return result[:max_display] + [f"... and {len(result) - max_display} more"]
|
||||
return result
|
||||
|
||||
|
||||
def extract_testing_section(body: str) -> str:
|
||||
"""Extract the testing section from a PR description."""
|
||||
if not body:
|
||||
return "No"
|
||||
|
||||
# Try various common testing section headers
|
||||
patterns = [
|
||||
r"##\s*Test(?:ing)?\s*(?:Plan|Strategy)?[ \t]*\r?\n",
|
||||
r"##\s*How (?:Has This Been |to )Test(?:ed)?[ \t]*\r?\n",
|
||||
r"##\s*Verification[ \t]*\r?\n",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, body, re.IGNORECASE)
|
||||
if match:
|
||||
section = body[match.end():]
|
||||
# Stop at next heading or HR
|
||||
end = re.search(r"\n##\s|\n---", section)
|
||||
if end:
|
||||
section = section[:end.start()]
|
||||
section = section.strip()
|
||||
if section:
|
||||
# Clean up markdown checkboxes and compress whitespace
|
||||
section = re.sub(r"<!--.*?-->", "", section, flags=re.DOTALL).strip()
|
||||
# Remove empty checkbox lines like "- [ ] Feature"
|
||||
lines = [l for l in section.split("\n") if l.strip()]
|
||||
cleaned = "\n".join(lines).strip()
|
||||
if cleaned and not re.match(r"^[\s\-\[\]xX]*$", cleaned):
|
||||
return cleaned
|
||||
return "No"
|
||||
|
||||
|
||||
def get_pr_details(repo: str, pr_number: int) -> dict | None:
|
||||
"""Fetch title, body, author, files, and approvers for a single PR."""
|
||||
output = run_gh([
|
||||
"pr", "view", str(pr_number), "--repo", repo,
|
||||
"--json", "number,title,body,author,files",
|
||||
])
|
||||
if not output:
|
||||
return None
|
||||
|
||||
data = json.loads(output)
|
||||
|
||||
# Get reviewers who approved
|
||||
reviews_output = run_gh([
|
||||
"api", f"repos/{repo}/pulls/{pr_number}/reviews",
|
||||
"--jq", '[.[] | select(.state == "APPROVED") | .user.login] | unique | join(", ")',
|
||||
])
|
||||
|
||||
files = data.get("files", []) or []
|
||||
body = (data.get("body") or "").strip()
|
||||
|
||||
return {
|
||||
"number": data["number"],
|
||||
"title": data["title"],
|
||||
"description": body,
|
||||
"author": data.get("author", {}).get("login", "unknown"),
|
||||
"approvers": reviews_output if reviews_output else "none",
|
||||
"files": files,
|
||||
"testing": extract_testing_section(body),
|
||||
}
|
||||
|
||||
|
||||
def generate_report(repo: str, release_pr: int, pr_details: list[dict], skipped_docs: list[dict]) -> str:
|
||||
"""Generate a risk assessment markdown report."""
|
||||
lines = [
|
||||
f"# Release Risk Assessment — PR #{release_pr}",
|
||||
"",
|
||||
f"**Repository:** {repo}",
|
||||
f"**Total PRs in release:** {len(pr_details) + len(skipped_docs)}",
|
||||
f"**Assessed PRs:** {len(pr_details)} (skipped {len(skipped_docs)} doc-only)",
|
||||
"",
|
||||
]
|
||||
|
||||
# Summary counts by risk
|
||||
risk_counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
||||
for pr in pr_details:
|
||||
risk_counts[pr["risk"]["level"]] += 1
|
||||
|
||||
lines.append(f"**Risk summary:** {risk_counts['HIGH']} High, {risk_counts['MEDIUM']} Medium, {risk_counts['LOW']} Low")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# Sort by risk score descending, then by PR number
|
||||
pr_details_sorted = sorted(pr_details, key=lambda x: (-x["risk"]["score"], x["number"]))
|
||||
|
||||
for i, pr in enumerate(pr_details_sorted, 1):
|
||||
risk = pr["risk"]
|
||||
risk_badge = {"HIGH": "HIGH RISK", "MEDIUM": "MEDIUM RISK", "LOW": "LOW RISK"}[risk["level"]]
|
||||
|
||||
lines.append(f"## {i}. #{pr['number']} — {pr['title']} `[{risk_badge}]`")
|
||||
lines.append("")
|
||||
lines.append(f"- **Author:** @{pr['author']}")
|
||||
lines.append(f"- **Approved by:** {pr['approvers']}")
|
||||
|
||||
# Include PR description for MEDIUM and HIGH risk PRs
|
||||
if risk["level"] in ("HIGH", "MEDIUM") and pr.get("description"):
|
||||
desc = pr["description"].replace("\n", " ").strip()
|
||||
if len(desc) > 500:
|
||||
desc = desc[:500] + "..."
|
||||
lines.append(f"- **Description:** {desc}")
|
||||
|
||||
lines.append(f"- **Files changed:** {risk['num_files']} (+{risk['total_additions']} / -{risk['total_deletions']})")
|
||||
|
||||
# Include individual file paths for MEDIUM and HIGH risk PRs
|
||||
if risk["level"] in ("HIGH", "MEDIUM") and pr.get("files"):
|
||||
file_lines = [f" - `{f['path']}` (+{f.get('additions', 0)}/-{f.get('deletions', 0)})" for f in pr["files"]]
|
||||
lines.append(f"- **Files:**")
|
||||
lines.extend(file_lines)
|
||||
else:
|
||||
lines.append(f"- **Key paths:** {', '.join(risk['key_paths']) if risk['key_paths'] else 'N/A'}")
|
||||
|
||||
if risk["factors"]:
|
||||
lines.append(f"- **Risk factors:** {'; '.join(risk['factors'])}")
|
||||
else:
|
||||
lines.append("- **Risk factors:** None notable")
|
||||
|
||||
# Testing section
|
||||
testing = pr["testing"]
|
||||
if testing == "No":
|
||||
lines.append("- **Testing:** No")
|
||||
else:
|
||||
# Show first 300 chars of testing section
|
||||
testing_summary = testing.replace("\n", " ").strip()
|
||||
if len(testing_summary) > 300:
|
||||
testing_summary = testing_summary[:300] + "..."
|
||||
lines.append(f"- **Testing:** {testing_summary}")
|
||||
|
||||
lines.append(f"- **Link:** https://github.com/{repo}/pull/{pr['number']}")
|
||||
lines.append("")
|
||||
|
||||
# Skipped docs section
|
||||
if skipped_docs:
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append(f"## Skipped: {len(skipped_docs)} documentation-only PRs")
|
||||
lines.append("")
|
||||
for pr in skipped_docs:
|
||||
lines.append(f"- #{pr['number']} — {pr['title']} (@{pr['author']})")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate a release risk assessment report")
|
||||
parser.add_argument("--pr", type=int, help="Release PR number (if omitted, searches for open release PRs)")
|
||||
parser.add_argument("--version", required=True, help="Version string to search for (e.g. 1.27.0)")
|
||||
parser.add_argument("--repo", default=REPO, help=f"GitHub repo (default: {REPO})")
|
||||
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||
parser.add_argument("--workers", type=int, default=5, help="Number of parallel workers (default: 5)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Step 1: Find the release PR
|
||||
release_pr = args.pr
|
||||
if not release_pr:
|
||||
print(f"Searching for open PR with '{args.version}' in title...", file=sys.stderr)
|
||||
release_pr = find_release_pr(args.repo, args.version)
|
||||
if not release_pr:
|
||||
print(f"[error] No open PR found with '{args.version}' in title", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"Using release PR #{release_pr}", file=sys.stderr)
|
||||
|
||||
# Step 2: Get PR body and extract linked PR numbers
|
||||
body = get_pr_body(args.repo, release_pr)
|
||||
if not body:
|
||||
print("[error] Could not fetch PR body", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
pr_numbers = extract_pr_numbers(body)
|
||||
if not pr_numbers:
|
||||
print("[error] No PR numbers found in the release body", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found {len(pr_numbers)} PRs in the release notes", file=sys.stderr)
|
||||
|
||||
# Step 3: Fetch details for each PR in parallel
|
||||
all_prs = []
|
||||
failed = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||
futures = {
|
||||
executor.submit(get_pr_details, args.repo, num): num
|
||||
for num in pr_numbers
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
num = futures[future]
|
||||
try:
|
||||
detail = future.result()
|
||||
if detail:
|
||||
all_prs.append(detail)
|
||||
print(f" Fetched #{num}: {detail['title'][:60]}", file=sys.stderr)
|
||||
else:
|
||||
failed.append(num)
|
||||
except Exception as e:
|
||||
print(f" [error] Failed to fetch #{num}: {e}", file=sys.stderr)
|
||||
failed.append(num)
|
||||
|
||||
if failed:
|
||||
print(f"\n[warn] Failed to fetch {len(failed)} PRs: {failed}", file=sys.stderr)
|
||||
|
||||
# Step 4: Filter and assess
|
||||
assessed_prs = []
|
||||
skipped_docs = []
|
||||
|
||||
for pr in all_prs:
|
||||
if is_doc_only(pr["files"]):
|
||||
skipped_docs.append(pr)
|
||||
print(f" [skip] #{pr['number']}: doc-only", file=sys.stderr)
|
||||
elif is_deps_only(pr["files"]):
|
||||
skipped_docs.append(pr)
|
||||
print(f" [skip] #{pr['number']}: deps-only", file=sys.stderr)
|
||||
else:
|
||||
pr["risk"] = assess_risk(pr["files"])
|
||||
assessed_prs.append(pr)
|
||||
|
||||
skipped_docs.sort(key=lambda x: x["number"])
|
||||
|
||||
print(f"\nAssessed {len(assessed_prs)} PRs, skipped {len(skipped_docs)} (doc/deps-only)", file=sys.stderr)
|
||||
|
||||
# Step 5: Generate report
|
||||
report = generate_report(args.repo, release_pr, assessed_prs, skipped_docs)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(report)
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
12
workflow_recipes/release_risk_check/run.sh
Executable file
12
workflow_recipes/release_risk_check/run.sh
Executable file
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 <version>"
|
||||
echo "Example: $0 1.27.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
goose run --recipe "$SCRIPT_DIR/recipe.yaml" --params "version=$1"
|
||||
Loading…
Add table
Add a link
Reference in a new issue