diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 00000000..30fc230b --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,25 @@ +name: "Internal: Tests" +on: + pull_request: + paths: + - ".github/workflows/gh-aw-fragments/**" + - "tests/**" + - "pyproject.toml" + push: + branches: [main] + paths: + - ".github/workflows/gh-aw-fragments/**" + - "tests/**" + - "pyproject.toml" + +jobs: + test: + name: pytest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v6 + + - name: Run tests + run: uv run --extra test pytest tests/ -v diff --git a/.github/workflows/downstream-users.lock.yml b/.github/workflows/downstream-users.lock.yml index 960063dd..47288110 100644 --- a/.github/workflows/downstream-users.lock.yml +++ b/.github/workflows/downstream-users.lock.yml @@ -36,7 +36,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"4d20eda00ac7fcf830c1d74576a9849cbc303f847db7fce88320ebc6930495db"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"1dba193ef82fd955d37fa68d92ca0bc2f0f511a7add8a6f482e3647cf9cb4f4a"} name: "Internal: Downstream Users" "on": @@ -872,16 +872,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-bug-exterminator.lock.yml b/.github/workflows/gh-aw-bug-exterminator.lock.yml index 48790d59..8d7c3718 100644 --- a/.github/workflows/gh-aw-bug-exterminator.lock.yml +++ b/.github/workflows/gh-aw-bug-exterminator.lock.yml @@ -37,7 +37,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"4238d50026baf2597d9d2cbf7a35b30caa0e23317077bc526f936acefb62f21c"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"d943487a7e29f0a90dd302d93a91a906e6dc21db6251f79c4037f528818f2fbc"} name: "Gh Aw Bug Exterminator" "on": @@ -921,16 +921,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-code-duplication-fixer.lock.yml b/.github/workflows/gh-aw-code-duplication-fixer.lock.yml index 95cf68b9..c80772d2 100644 --- a/.github/workflows/gh-aw-code-duplication-fixer.lock.yml +++ b/.github/workflows/gh-aw-code-duplication-fixer.lock.yml @@ -37,7 +37,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"287b716cb4d03892116ac41b6c12b60417cdae96fa5e531e5508422e13fa449f"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"bb7aaec4d46c4fd1735e301a045d4a75ed19ae00193b4d4589446e910fcaee31"} name: "Code Duplication Fixer" "on": @@ -923,16 +923,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-code-simplifier.lock.yml b/.github/workflows/gh-aw-code-simplifier.lock.yml index dc39e3ed..14e55770 100644 --- a/.github/workflows/gh-aw-code-simplifier.lock.yml +++ b/.github/workflows/gh-aw-code-simplifier.lock.yml @@ -37,7 +37,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"bf9d9cc33799d1a67c4a541f1e167753525f51d89ff3069ab5bce6d5e9a1da50"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"0d2b6c05c633aab7c3f691918e60e77c216872bc01bdc7cc0960dc341aaa32b1"} name: "Code Simplifier" "on": @@ -938,16 +938,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-fragments/pr-context.md b/.github/workflows/gh-aw-fragments/pr-context.md index 11b09b80..bac69c97 100644 --- a/.github/workflows/gh-aw-fragments/pr-context.md +++ b/.github/workflows/gh-aw-fragments/pr-context.md @@ -51,6 +51,7 @@ steps: comments(first: 100) { nodes { id + databaseId body author { login } createdAt @@ -105,7 +106,7 @@ steps: | `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` | | `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` | | `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body | - | `review_comments.json` | All review threads (GraphQL) — each thread has `id`, `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with body/author | + | `review_comments.json` | All review threads (GraphQL) — each thread has `id` (node ID for resolving), `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with `id`, `databaseId` (numeric REST ID for replies), body/author | | `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` | | `comments.json` | PR discussion comments (not inline) | | `issue-{N}.json` | Linked issue details (one file per linked issue, if any) | diff --git a/.github/workflows/gh-aw-fragments/safe-output-create-pr.md b/.github/workflows/gh-aw-fragments/safe-output-create-pr.md index a8c20bb5..ce43e47d 100644 --- a/.github/workflows/gh-aw-fragments/safe-output-create-pr.md +++ b/.github/workflows/gh-aw-fragments/safe-output-create-pr.md @@ -3,16 +3,52 @@ safe-inputs: ready-to-make-pr: description: "Run the PR readiness checklist before creating or updating a PR" py: | - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) safe-outputs: create-pull-request: draft: ${{ inputs.draft-prs }} diff --git a/.github/workflows/gh-aw-fragments/safe-output-push-to-pr.md b/.github/workflows/gh-aw-fragments/safe-output-push-to-pr.md index f563f684..e7f1c6ae 100644 --- a/.github/workflows/gh-aw-fragments/safe-output-push-to-pr.md +++ b/.github/workflows/gh-aw-fragments/safe-output-push-to-pr.md @@ -3,16 +3,52 @@ safe-inputs: ready-to-make-pr: description: "Run the PR readiness checklist before creating or updating a PR" py: | - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) safe-outputs: push-to-pull-request-branch: github-token-for-extra-empty-commit: ${{ secrets.EXTRA_COMMIT_GITHUB_TOKEN }} diff --git a/.github/workflows/gh-aw-fragments/safe-output-reply-to-review-comment.md b/.github/workflows/gh-aw-fragments/safe-output-reply-to-review-comment.md index 520d4354..486cb88e 100644 --- a/.github/workflows/gh-aw-fragments/safe-output-reply-to-review-comment.md +++ b/.github/workflows/gh-aw-fragments/safe-output-reply-to-review-comment.md @@ -6,7 +6,7 @@ safe-outputs: ## reply-to-pull-request-review-comment Limitations -- **Required field**: `comment_id` — the ID of the review comment to reply to. This is the numeric REST comment ID from `get_review_comments`. +- **Required field**: `comment_id` — the numeric REST comment ID (e.g., `2481734562`). From `get_review_comments` this is the `id` field. From `/tmp/pr-context/review_comments.json` (GraphQL) this is the `databaseId` field. Do not pass GraphQL node IDs (e.g., `IC_kwDONVGiRc6...`) — those will fail. - **Body**: Max 65,536 characters. Keep well under this limit. - **Purpose**: Reply directly to a specific review comment thread to explain your reasoning when you disagree with or skip feedback. Do NOT use `add_comment` for this — use this tool to keep replies in context. - **Max per run**: 10 replies per workflow run. diff --git a/.github/workflows/gh-aw-issue-fixer.lock.yml b/.github/workflows/gh-aw-issue-fixer.lock.yml index ffbc3b63..e3a097ee 100644 --- a/.github/workflows/gh-aw-issue-fixer.lock.yml +++ b/.github/workflows/gh-aw-issue-fixer.lock.yml @@ -38,7 +38,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"3728d58a902ce5f44551997d7f4db8230187f891b449a33c01addc059692e428"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"72c94eb38a709725cfed3a3e99d575dc9d93d7c654f20e4540dcac1a8698d5dc"} name: "Issue Fixer" "on": @@ -971,16 +971,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-mention-in-issue-no-sandbox.lock.yml b/.github/workflows/gh-aw-mention-in-issue-no-sandbox.lock.yml index 345de00d..f5b1579d 100644 --- a/.github/workflows/gh-aw-mention-in-issue-no-sandbox.lock.yml +++ b/.github/workflows/gh-aw-mention-in-issue-no-sandbox.lock.yml @@ -39,7 +39,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"99fbad365c061074cb5bbd772bb195344ffbe6f39a470148d29ffe5bbf4f3bd8"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"54a63f522d6040758fcb860c66b5e6ffba194e80bff65818ae64b9012150c25f"} name: "Mention in Issue (no sandbox)" "on": @@ -1059,16 +1059,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-mention-in-issue.lock.yml b/.github/workflows/gh-aw-mention-in-issue.lock.yml index 0fd5db81..a2feb56e 100644 --- a/.github/workflows/gh-aw-mention-in-issue.lock.yml +++ b/.github/workflows/gh-aw-mention-in-issue.lock.yml @@ -39,7 +39,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"0ae9f74f88703cfd13359b3cc4b30465b644fb2b99e7841481b54147175a482f"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"2ded4a298690475553c0f0f61d4fb5a3a4bb04e89d59e7559a521b9fc81abc39"} name: "Mention in Issue" "on": @@ -1063,16 +1063,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-mention-in-pr-by-id.lock.yml b/.github/workflows/gh-aw-mention-in-pr-by-id.lock.yml index 868d1616..1f96af55 100644 --- a/.github/workflows/gh-aw-mention-in-pr-by-id.lock.yml +++ b/.github/workflows/gh-aw-mention-in-pr-by-id.lock.yml @@ -43,7 +43,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"8e546cd412f75da2fc56b687cfed07f0c8fa250c98abf4f79bf121a5a31d007c"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"348bb144fcffb257a123572220f49d538279c90a52b15fe61b45c89b846695b5"} name: "Mention in PR by ID" "on": @@ -649,7 +649,7 @@ jobs: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number || inputs.target-pr-number || github.event.issue.number }} name: Fetch PR context to disk - run: "set -euo pipefail\nmkdir -p /tmp/pr-context\n\n# PR metadata\ngh pr view \"$PR_NUMBER\" --json title,body,author,baseRefName,headRefName,url \\\n > /tmp/pr-context/pr.json\n\n# Full diff\nif ! gh pr diff \"$PR_NUMBER\" > /tmp/pr-context/pr.diff; then\n echo \"::warning::Failed to fetch full PR diff; per-file diffs from files.json are still available.\"\n : > /tmp/pr-context/pr.diff\nfi\n\n# Changed files list (--paginate may output concatenated arrays; jq -s 'add' merges them)\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/files.json\n\n# Per-file diffs\njq -c '.[]' /tmp/pr-context/files.json | while IFS= read -r entry; do\n filename=$(echo \"$entry\" | jq -r '.filename')\n mkdir -p \"/tmp/pr-context/diffs/$(dirname \"$filename\")\"\n echo \"$entry\" | jq -r '.patch // empty' > \"/tmp/pr-context/diffs/${filename}.diff\"\ndone\n\n# Existing reviews\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/reviews.json\n\n# Review threads with resolution status (GraphQL — REST lacks isResolved/isOutdated)\ngh api graphql --paginate -f query='\n query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n reviewThreads(first: 100, after: $endCursor) {\n pageInfo { hasNextPage endCursor }\n nodes {\n id\n isResolved\n isOutdated\n isCollapsed\n path\n line\n startLine\n comments(first: 100) {\n nodes {\n id\n body\n author { login }\n createdAt\n }\n }\n }\n }\n }\n }\n }\n' -F owner=\"${GITHUB_REPOSITORY%/*}\" -F repo=\"${GITHUB_REPOSITORY#*/}\" -F \"number=$PR_NUMBER\" \\\n --jq '.data.repository.pullRequest.reviewThreads.nodes' \\\n | jq -s 'add // []' > /tmp/pr-context/review_comments.json\n\n# Per-file review threads (mirrors diffs/ structure)\njq -c '.[]' /tmp/pr-context/review_comments.json | while IFS= read -r thread; do\n filepath=$(echo \"$thread\" | jq -r '.path // empty')\n [ -z \"$filepath\" ] && continue\n mkdir -p \"/tmp/pr-context/threads/$(dirname \"$filepath\")\"\n echo \"$thread\" >> \"/tmp/pr-context/threads/${filepath}.jsonl\"\ndone\n# Convert per-file JSONL to proper JSON arrays\nmkdir -p /tmp/pr-context/threads\nfind /tmp/pr-context/threads -name '*.jsonl' 2>/dev/null | while IFS= read -r jsonl; do\n jq -s '.' \"$jsonl\" > \"${jsonl%.jsonl}.json\"\n rm \"$jsonl\"\ndone\n\n# PR discussion comments\ngh api \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/comments.json\n\n# Linked issues\njq -r '.body // \"\"' /tmp/pr-context/pr.json 2>/dev/null \\\n | grep -oiE '(fixes|closes|resolves)\\s+#[0-9]+' \\\n | grep -oE '[0-9]+$' \\\n | sort -u \\\n | while read -r issue; do\n gh api \"repos/$GITHUB_REPOSITORY/issues/$issue\" > \"/tmp/pr-context/issue-${issue}.json\" || true\n done || true\n\n# Write manifest\ncat > /tmp/pr-context/README.md << 'MANIFEST'\n# PR Context\n\nPre-fetched PR data. All files are in `/tmp/pr-context/`.\n\n| File | Description |\n| --- | --- |\n| `pr.json` | PR metadata — title, body, author, base/head branches, URL |\n| `pr.diff` | Full unified diff of all changes |\n| `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` |\n| `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` |\n| `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body |\n| `review_comments.json` | All review threads (GraphQL) — each thread has `id`, `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with body/author |\n| `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` |\n| `comments.json` | PR discussion comments (not inline) |\n| `issue-{N}.json` | Linked issue details (one file per linked issue, if any) |\n| `agents.md` | Repository conventions from `generate_agents_md` (if written by agent) |\n| `review-instructions.md` | Review instructions, criteria, and calibration examples (if written by review-process fragment) |\nMANIFEST\n\necho \"PR context written to /tmp/pr-context/\"\nls -la /tmp/pr-context/" + run: "set -euo pipefail\nmkdir -p /tmp/pr-context\n\n# PR metadata\ngh pr view \"$PR_NUMBER\" --json title,body,author,baseRefName,headRefName,url \\\n > /tmp/pr-context/pr.json\n\n# Full diff\nif ! gh pr diff \"$PR_NUMBER\" > /tmp/pr-context/pr.diff; then\n echo \"::warning::Failed to fetch full PR diff; per-file diffs from files.json are still available.\"\n : > /tmp/pr-context/pr.diff\nfi\n\n# Changed files list (--paginate may output concatenated arrays; jq -s 'add' merges them)\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/files.json\n\n# Per-file diffs\njq -c '.[]' /tmp/pr-context/files.json | while IFS= read -r entry; do\n filename=$(echo \"$entry\" | jq -r '.filename')\n mkdir -p \"/tmp/pr-context/diffs/$(dirname \"$filename\")\"\n echo \"$entry\" | jq -r '.patch // empty' > \"/tmp/pr-context/diffs/${filename}.diff\"\ndone\n\n# Existing reviews\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/reviews.json\n\n# Review threads with resolution status (GraphQL — REST lacks isResolved/isOutdated)\ngh api graphql --paginate -f query='\n query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n reviewThreads(first: 100, after: $endCursor) {\n pageInfo { hasNextPage endCursor }\n nodes {\n id\n isResolved\n isOutdated\n isCollapsed\n path\n line\n startLine\n comments(first: 100) {\n nodes {\n id\n databaseId\n body\n author { login }\n createdAt\n }\n }\n }\n }\n }\n }\n }\n' -F owner=\"${GITHUB_REPOSITORY%/*}\" -F repo=\"${GITHUB_REPOSITORY#*/}\" -F \"number=$PR_NUMBER\" \\\n --jq '.data.repository.pullRequest.reviewThreads.nodes' \\\n | jq -s 'add // []' > /tmp/pr-context/review_comments.json\n\n# Per-file review threads (mirrors diffs/ structure)\njq -c '.[]' /tmp/pr-context/review_comments.json | while IFS= read -r thread; do\n filepath=$(echo \"$thread\" | jq -r '.path // empty')\n [ -z \"$filepath\" ] && continue\n mkdir -p \"/tmp/pr-context/threads/$(dirname \"$filepath\")\"\n echo \"$thread\" >> \"/tmp/pr-context/threads/${filepath}.jsonl\"\ndone\n# Convert per-file JSONL to proper JSON arrays\nmkdir -p /tmp/pr-context/threads\nfind /tmp/pr-context/threads -name '*.jsonl' 2>/dev/null | while IFS= read -r jsonl; do\n jq -s '.' \"$jsonl\" > \"${jsonl%.jsonl}.json\"\n rm \"$jsonl\"\ndone\n\n# PR discussion comments\ngh api \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/comments.json\n\n# Linked issues\njq -r '.body // \"\"' /tmp/pr-context/pr.json 2>/dev/null \\\n | grep -oiE '(fixes|closes|resolves)\\s+#[0-9]+' \\\n | grep -oE '[0-9]+$' \\\n | sort -u \\\n | while read -r issue; do\n gh api \"repos/$GITHUB_REPOSITORY/issues/$issue\" > \"/tmp/pr-context/issue-${issue}.json\" || true\n done || true\n\n# Write manifest\ncat > /tmp/pr-context/README.md << 'MANIFEST'\n# PR Context\n\nPre-fetched PR data. All files are in `/tmp/pr-context/`.\n\n| File | Description |\n| --- | --- |\n| `pr.json` | PR metadata — title, body, author, base/head branches, URL |\n| `pr.diff` | Full unified diff of all changes |\n| `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` |\n| `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` |\n| `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body |\n| `review_comments.json` | All review threads (GraphQL) — each thread has `id` (node ID for resolving), `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with `id`, `databaseId` (numeric REST ID for replies), body/author |\n| `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` |\n| `comments.json` | PR discussion comments (not inline) |\n| `issue-{N}.json` | Linked issue details (one file per linked issue, if any) |\n| `agents.md` | Repository conventions from `generate_agents_md` (if written by agent) |\n| `review-instructions.md` | Review instructions, criteria, and calibration examples (if written by review-process fragment) |\nMANIFEST\n\necho \"PR context written to /tmp/pr-context/\"\nls -la /tmp/pr-context/" - name: Write review instructions to disk run: "mkdir -p /tmp/pr-context\ncat > /tmp/pr-context/review-instructions.md << 'REVIEW_EOF'\n# Review Instructions for Sub-agents\n\nYou are a code review sub-agent. Read these instructions, then review the PR files in the order provided in your prompt.\n\n## Context\n\nBefore reviewing files, read these to understand the PR:\n\n1. `/tmp/pr-context/pr.json` — PR title, description, author, and branches. Understand what the PR is trying to accomplish.\n2. `/tmp/pr-context/agents.md` — Repository coding conventions and guidelines (if it exists).\n3. `/tmp/pr-context/review_comments.json` — Existing review threads. Note which files already have threads so you don't duplicate.\n4. `/tmp/pr-context/issue-*.json` — Linked issue details (if any). Understand the motivation and acceptance criteria.\n\n## Process\n\nReview the PR diff file by file in your assigned order. For each changed file:\n\n1. **Read the diff** for this file from `/tmp/pr-context/diffs/.diff` to understand what changed. If the diff is empty or truncated (e.g., binary files or very large changes), fall back to reading the full file from the workspace and comparing against context.\n2. **Read the full file from the workspace.** The PR branch is checked out locally — open the file directly to get complete contents with line numbers.\n3. **Check existing threads** for this file from `/tmp/pr-context/threads/.json` (if it exists). Skip issues that are already under discussion — each thread has `isResolved` and `isOutdated` fields.\n4. **Identify potential issues** matching the review criteria below.\n5. **Quick-check each issue** before including it:\n - What specific code pattern or change triggers this concern?\n - Is there an obvious guard, handler, or mitigation visible in the immediate context?\n - Can you describe a concrete failure scenario (the `evidence` field)? If you cannot articulate what specific input or state triggers the problem, drop the finding.\n - If the issue is clearly handled, skip it. If you're unsure, include it — the parent will verify.\n6. **Add to your findings list.** Do NOT leave inline comments — you don't have that tool. Return findings in this format:\n\n```\n- file: path/to/file\n line: 42\n severity: HIGH\n title: Brief title\n description: What the issue is and why it matters\n evidence: The specific code pattern and failure scenario\n suggestion: corrected code here (optional — only if you can provide a concrete fix)\n```\n\n**Review every file in your assigned order.** Files reviewed earlier get more attention, which is why different sub-agents use different orderings.\n\n**Check existing threads** — per-file threads are at `/tmp/pr-context/threads/.json` (step 3 above). The full list is at `/tmp/pr-context/review_comments.json`. Do not flag issues that are already under discussion (resolved or unresolved). For outdated threads, only re-flag if the issue still applies to the current diff.\n\n**Return your full findings list** when done, or an empty list if no issues were found.\n\n## Review Criteria\n\nFocus on these categories in priority order:\n\n1. Security vulnerabilities (injection, XSS, auth bypass, secrets exposure)\n2. Logic bugs that could cause runtime failures or incorrect behavior\n3. Data integrity issues (race conditions, missing transactions, corruption risk)\n4. Performance bottlenecks (N+1 queries, memory leaks, blocking operations)\n5. Error handling gaps (unhandled exceptions, missing validation)\n6. Breaking changes to public APIs without migration path\n7. Missing or incorrect test coverage for critical paths\n\n## What NOT to Flag\n\nOnly review the diff — do not flag issues in unchanged code, pre-existing problems not introduced by this PR, or style preferences handled by linters or formatters.\n\n**Common false positives** — these patterns look like issues but usually aren't. Before flagging anything in these categories, confirm the problem is real by reading the surrounding code:\n\n- **Security — input already sanitized:** Don't flag injection or XSS risks when inputs are sanitized upstream, parameterized queries are used, or the framework auto-escapes output.\n- **Null/undefined — guarded elsewhere:** Don't flag potential null dereferences if the value is guaranteed by a type guard, assertion, schema validation, or upstream null check.\n- **Error handling — handled at a different layer:** Don't flag missing try/catch if the caller, middleware, or framework catches and handles the error (e.g., Express error middleware, React error boundaries).\n- **Performance — theoretical, not practical:** Don't flag algorithmic complexity (e.g., O(n^2)) unless N is demonstrably large enough to matter in the actual usage context. \"This could be slow\" without evidence is not actionable.\n- **Validation — exists at another layer:** Don't flag missing input validation if it's handled by an API gateway, middleware, schema validator, or type system.\n- **Test coverage — trivial or generated code:** Don't flag missing tests for trivial getters/setters, auto-generated code, or simple delegation methods.\n- **Style or naming — not in coding guidelines:** Don't flag naming conventions or code style unless they violate the repository's documented coding guidelines (from `generate_agents_md` or CONTRIBUTING docs).\n\n**Existing review threads** — check BEFORE flagging any issue:\n\n- **Resolved with reviewer reply** (e.g. \"This is intentional\") — reviewer's decision is final. Do NOT re-flag.\n- **Resolved without reply** — author likely fixed it. Do NOT re-raise unless the fix introduced a new problem.\n- **Unresolved** — already flagged. Do NOT duplicate.\n- **Outdated** — only re-flag if the issue still applies to the current diff.\n\nWhen in doubt, do not duplicate. Redundant comments erode trust.\n\nFinding no issues is a valid and valuable outcome. An empty findings list is better than findings that waste the author's time or erode trust. Do not manufacture findings to justify your review — if the code is sound, return an empty list.\n\n## Severity Classification\n\nDetermine severity AFTER investigating the issue, not before. First identify the problem and trace through the code, then assign a severity based on the evidence you found.\n\n- 🔴 CRITICAL — Must fix before merge (security vulnerabilities, data corruption, production-breaking bugs)\n- 🟠 HIGH — Should fix before merge (logic errors, missing validation, significant performance issues)\n- 🟡 MEDIUM — Address soon, non-blocking (error handling gaps, suboptimal patterns, missing edge cases)\n- ⚪ LOW — Author discretion (minor improvements, documentation gaps)\n- 💬 NITPICK — Truly optional (stylistic preferences, alternative approaches)\n\n## Review Intensity\n\nThe review intensity is `${{ inputs.intensity || 'balanced' }}`.\n\n- **conservative**: High evidence bar. Only flag when you can demonstrate a concrete failure scenario. If you can construct a reasonable counterargument, do not flag. Approval with zero findings is the expected outcome for most PRs.\n- **balanced**: Standard evidence bar. Flag when you can point to specific code that would fail. If the issue is ambiguous, lean toward not flagging.\n- **aggressive**: Lower evidence bar. Flag when evidence exists even if the failure scenario is not fully confirmed. Improvement suggestions welcome but must cite specific code.\n\n## Calibration Examples\n\nUse these examples to calibrate your judgment. Each pair shows a real issue and a similar-looking pattern that is NOT an issue.\n\n### Example 1: Null/Undefined Access\n\n**True positive — flag this:**\n\n```js\n// PR adds this handler\napp.get('/user/:id', async (req, res) => {\n const user = await db.findUser(req.params.id);\n res.json({ name: user.name, email: user.email });\n});\n```\n\nWhy flag: `db.findUser()` can return `null` when no user matches the ID. Accessing `user.name` will throw a TypeError at runtime. No upstream guard exists — the route handler is the entry point.\n\n**False positive — do NOT flag this:**\n\n```ts\n// PR adds this line inside an existing function\nconst settings = user.getSettings();\n```\n\nWhy skip: Reading the full file reveals `user` is typed as `User` (not `User | null`), and the calling function only runs after `authenticateUser()` middleware which guarantees a valid user object. The null case is handled at a different layer.\n\n### Example 2: SQL Injection\n\n**True positive — flag this:**\n\n```python\n# PR adds this query\ncursor.execute(f\"SELECT * FROM orders WHERE customer_id = '{customer_id}'\")\n```\n\nWhy flag: String interpolation in a SQL query with user-controlled input (`customer_id` comes from the request). No parameterization or sanitization anywhere in the call chain.\n\n**False positive — do NOT flag this:**\n\n```python\n# PR adds this query\ncursor.execute(f\"SELECT * FROM orders WHERE status = '{OrderStatus.PENDING.value}'\")\n```\n\nWhy skip: The interpolated value is a hardcoded enum constant (`OrderStatus.PENDING`), not user input. There is no injection vector.\n\n### Example 3: Borderline — Do NOT Flag\n\n```go\n// PR adds this function\nfunc processItems(items []Item) []Result {\n results := make([]Result, 0)\n for _, item := range items {\n for _, tag := range item.Tags {\n results = append(results, process(item, tag))\n }\n }\n return results\n}\n```\n\nThis looks like an O(n*m) performance concern. But without evidence that `items` or `Tags` are large in practice, this is speculative. The function processes a bounded dataset (items from a single user request). Do not flag theoretical performance issues without evidence of real-world impact.\nREVIEW_EOF" - env: @@ -1219,16 +1219,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-mention-in-pr-no-sandbox.lock.yml b/.github/workflows/gh-aw-mention-in-pr-no-sandbox.lock.yml index 90a30702..1904b3bc 100644 --- a/.github/workflows/gh-aw-mention-in-pr-no-sandbox.lock.yml +++ b/.github/workflows/gh-aw-mention-in-pr-no-sandbox.lock.yml @@ -36,6 +36,7 @@ # - gh-aw-fragments/runtime-setup.md # - gh-aw-fragments/safe-output-add-comment-pr.md # - gh-aw-fragments/safe-output-push-to-pr.md +# - gh-aw-fragments/safe-output-reply-to-review-comment.md # - gh-aw-fragments/safe-output-resolve-thread.md # - gh-aw-fragments/safe-output-review-comment.md # - gh-aw-fragments/safe-output-submit-review.md @@ -43,7 +44,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"269c9f4d79f0e82ef451152e37a596890a9bed2ffd411ec297a908dd97ef8ab8"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"e3f38a4324efedbed42d66c68f23e3f24397eae498756c8a0fff7a348db99f86"} name: "Mention in PR (no sandbox)" "on": @@ -198,7 +199,7 @@ jobs: cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' - Tools: add_comment, create_pull_request_review_comment, submit_pull_request_review, resolve_pull_request_review_thread, push_to_pull_request_branch, missing_tool, missing_data, noop + Tools: add_comment, create_pull_request_review_comment, submit_pull_request_review, reply_to_pull_request_review_comment, resolve_pull_request_review_thread, push_to_pull_request_branch, missing_tool, missing_data, noop GH_AW_PROMPT_EOF cat "/opt/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" cat << 'GH_AW_PROMPT_EOF' @@ -465,6 +466,14 @@ jobs: - **Max per run**: __GH_AW_EXPR_7F2A702A__ thread resolutions per workflow run. GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + ## reply-to-pull-request-review-comment Limitations + + - **Required field**: `comment_id` — the numeric REST comment ID (e.g., `2481734562`). From `get_review_comments` this is the `id` field. From `/tmp/pr-context/review_comments.json` (GraphQL) this is the `databaseId` field. Do not pass GraphQL node IDs (e.g., `IC_kwDONVGiRc6...`) — those will fail. + - **Body**: Max 65,536 characters. Keep well under this limit. + - **Purpose**: Reply directly to a specific review comment thread to explain your reasoning when you disagree with or skip feedback. Do NOT use `add_comment` for this — use this tool to keep replies in context. + - **Max per run**: 10 replies per workflow run. + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' @@ -481,7 +490,7 @@ jobs: ## Constraints - - **CAN**: Read files, search code, modify files locally, run tests and commands, leave inline review comments, submit reviews, resolve review threads, push to the PR branch (same-repo only) + - **CAN**: Read files, search code, modify files locally, run tests and commands, leave inline review comments, submit reviews, reply to review threads, resolve review threads, push to the PR branch (same-repo only) - **CANNOT**: Push to fork PR branches, merge PRs, delete branches When pushing changes, the workspace already has the PR branch checked out. Make your changes, commit them locally, then use `push_to_pull_request_branch`. @@ -518,10 +527,13 @@ jobs: **If asked to fix code or address review feedback:** - Read `/tmp/pr-context/review_comments.json` to see open review threads and understand what needs to be addressed. - - Make the changes in the workspace. + - For each unresolved thread you address: + - Make the code changes in the workspace. + - If the fix isn't obvious from the code change alone, call `reply_to_pull_request_review_comment` with the comment's numeric ID to briefly explain what you changed. + - If you disagree with feedback or it's unclear, call `reply_to_pull_request_review_comment` to explain your reasoning instead of making changes. Do NOT resolve the thread — let the reviewer decide. - Run required repo commands (lint/build/test) from README, CONTRIBUTING, DEVELOPING, Makefile, or CI config relevant to the change and include results. If required commands cannot be run, explain why and do not push changes. - Commit your changes locally, then use `push_to_pull_request_branch` to push them. - - After pushing, resolve each addressed review thread by calling `resolve_pull_request_review_thread` with the thread's node ID (the `id` field from `get_review_comments`, e.g., `PRRT_kwDO...`). Only resolve threads you have actually addressed — do not resolve threads you skipped or disagreed with. + - After pushing, resolve every review thread that your changes address by calling `resolve_pull_request_review_thread` with the thread's GraphQL node ID (the `id` field, e.g., `PRRT_kwDO...`). This includes threads left by other reviewers AND threads from your own prior reviews. Check `/tmp/pr-context/review_comments.json` for all unresolved threads (`isResolved: false`) — `isOutdated` threads have had the underlying code changed since the comment was made, so check whether your changes address them. Do NOT resolve threads you disagreed with, skipped, or only partially addressed — leave those open for the reviewer. - **Fork PRs**: Check via `pull_request_read` with method `get` whether the PR head repo differs from the base repo. If it's a fork, you cannot push — reply explaining that you do not have permission to push to fork branches and suggest that the PR author apply the changes themselves. This is a GitHub security limitation. You can still review code, make local changes, and provide suggestions. **If asked a question about the code:** @@ -538,7 +550,8 @@ jobs: **Additional tools:** - `push_to_pull_request_branch` — push committed changes to the PR branch (same-repo PRs only) - - `resolve_pull_request_review_thread` — resolve a review thread after addressing the feedback (pass the thread's node ID) + - `reply_to_pull_request_review_comment` — reply inline to a review comment thread to explain what you changed or why you disagree + - `resolve_pull_request_review_thread` — resolve a review thread after addressing the feedback (pass the thread's GraphQL node ID) __GH_AW_EXPR_49B959F1__ @@ -710,7 +723,7 @@ jobs: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number || inputs.target-pr-number || github.event.issue.number }} name: Fetch PR context to disk - run: "set -euo pipefail\nmkdir -p /tmp/pr-context\n\n# PR metadata\ngh pr view \"$PR_NUMBER\" --json title,body,author,baseRefName,headRefName,url \\\n > /tmp/pr-context/pr.json\n\n# Full diff\nif ! gh pr diff \"$PR_NUMBER\" > /tmp/pr-context/pr.diff; then\n echo \"::warning::Failed to fetch full PR diff; per-file diffs from files.json are still available.\"\n : > /tmp/pr-context/pr.diff\nfi\n\n# Changed files list (--paginate may output concatenated arrays; jq -s 'add' merges them)\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/files.json\n\n# Per-file diffs\njq -c '.[]' /tmp/pr-context/files.json | while IFS= read -r entry; do\n filename=$(echo \"$entry\" | jq -r '.filename')\n mkdir -p \"/tmp/pr-context/diffs/$(dirname \"$filename\")\"\n echo \"$entry\" | jq -r '.patch // empty' > \"/tmp/pr-context/diffs/${filename}.diff\"\ndone\n\n# Existing reviews\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/reviews.json\n\n# Review threads with resolution status (GraphQL — REST lacks isResolved/isOutdated)\ngh api graphql --paginate -f query='\n query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n reviewThreads(first: 100, after: $endCursor) {\n pageInfo { hasNextPage endCursor }\n nodes {\n id\n isResolved\n isOutdated\n isCollapsed\n path\n line\n startLine\n comments(first: 100) {\n nodes {\n id\n body\n author { login }\n createdAt\n }\n }\n }\n }\n }\n }\n }\n' -F owner=\"${GITHUB_REPOSITORY%/*}\" -F repo=\"${GITHUB_REPOSITORY#*/}\" -F \"number=$PR_NUMBER\" \\\n --jq '.data.repository.pullRequest.reviewThreads.nodes' \\\n | jq -s 'add // []' > /tmp/pr-context/review_comments.json\n\n# Per-file review threads (mirrors diffs/ structure)\njq -c '.[]' /tmp/pr-context/review_comments.json | while IFS= read -r thread; do\n filepath=$(echo \"$thread\" | jq -r '.path // empty')\n [ -z \"$filepath\" ] && continue\n mkdir -p \"/tmp/pr-context/threads/$(dirname \"$filepath\")\"\n echo \"$thread\" >> \"/tmp/pr-context/threads/${filepath}.jsonl\"\ndone\n# Convert per-file JSONL to proper JSON arrays\nmkdir -p /tmp/pr-context/threads\nfind /tmp/pr-context/threads -name '*.jsonl' 2>/dev/null | while IFS= read -r jsonl; do\n jq -s '.' \"$jsonl\" > \"${jsonl%.jsonl}.json\"\n rm \"$jsonl\"\ndone\n\n# PR discussion comments\ngh api \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/comments.json\n\n# Linked issues\njq -r '.body // \"\"' /tmp/pr-context/pr.json 2>/dev/null \\\n | grep -oiE '(fixes|closes|resolves)\\s+#[0-9]+' \\\n | grep -oE '[0-9]+$' \\\n | sort -u \\\n | while read -r issue; do\n gh api \"repos/$GITHUB_REPOSITORY/issues/$issue\" > \"/tmp/pr-context/issue-${issue}.json\" || true\n done || true\n\n# Write manifest\ncat > /tmp/pr-context/README.md << 'MANIFEST'\n# PR Context\n\nPre-fetched PR data. All files are in `/tmp/pr-context/`.\n\n| File | Description |\n| --- | --- |\n| `pr.json` | PR metadata — title, body, author, base/head branches, URL |\n| `pr.diff` | Full unified diff of all changes |\n| `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` |\n| `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` |\n| `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body |\n| `review_comments.json` | All review threads (GraphQL) — each thread has `id`, `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with body/author |\n| `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` |\n| `comments.json` | PR discussion comments (not inline) |\n| `issue-{N}.json` | Linked issue details (one file per linked issue, if any) |\n| `agents.md` | Repository conventions from `generate_agents_md` (if written by agent) |\n| `review-instructions.md` | Review instructions, criteria, and calibration examples (if written by review-process fragment) |\nMANIFEST\n\necho \"PR context written to /tmp/pr-context/\"\nls -la /tmp/pr-context/" + run: "set -euo pipefail\nmkdir -p /tmp/pr-context\n\n# PR metadata\ngh pr view \"$PR_NUMBER\" --json title,body,author,baseRefName,headRefName,url \\\n > /tmp/pr-context/pr.json\n\n# Full diff\nif ! gh pr diff \"$PR_NUMBER\" > /tmp/pr-context/pr.diff; then\n echo \"::warning::Failed to fetch full PR diff; per-file diffs from files.json are still available.\"\n : > /tmp/pr-context/pr.diff\nfi\n\n# Changed files list (--paginate may output concatenated arrays; jq -s 'add' merges them)\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/files.json\n\n# Per-file diffs\njq -c '.[]' /tmp/pr-context/files.json | while IFS= read -r entry; do\n filename=$(echo \"$entry\" | jq -r '.filename')\n mkdir -p \"/tmp/pr-context/diffs/$(dirname \"$filename\")\"\n echo \"$entry\" | jq -r '.patch // empty' > \"/tmp/pr-context/diffs/${filename}.diff\"\ndone\n\n# Existing reviews\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/reviews.json\n\n# Review threads with resolution status (GraphQL — REST lacks isResolved/isOutdated)\ngh api graphql --paginate -f query='\n query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n reviewThreads(first: 100, after: $endCursor) {\n pageInfo { hasNextPage endCursor }\n nodes {\n id\n isResolved\n isOutdated\n isCollapsed\n path\n line\n startLine\n comments(first: 100) {\n nodes {\n id\n databaseId\n body\n author { login }\n createdAt\n }\n }\n }\n }\n }\n }\n }\n' -F owner=\"${GITHUB_REPOSITORY%/*}\" -F repo=\"${GITHUB_REPOSITORY#*/}\" -F \"number=$PR_NUMBER\" \\\n --jq '.data.repository.pullRequest.reviewThreads.nodes' \\\n | jq -s 'add // []' > /tmp/pr-context/review_comments.json\n\n# Per-file review threads (mirrors diffs/ structure)\njq -c '.[]' /tmp/pr-context/review_comments.json | while IFS= read -r thread; do\n filepath=$(echo \"$thread\" | jq -r '.path // empty')\n [ -z \"$filepath\" ] && continue\n mkdir -p \"/tmp/pr-context/threads/$(dirname \"$filepath\")\"\n echo \"$thread\" >> \"/tmp/pr-context/threads/${filepath}.jsonl\"\ndone\n# Convert per-file JSONL to proper JSON arrays\nmkdir -p /tmp/pr-context/threads\nfind /tmp/pr-context/threads -name '*.jsonl' 2>/dev/null | while IFS= read -r jsonl; do\n jq -s '.' \"$jsonl\" > \"${jsonl%.jsonl}.json\"\n rm \"$jsonl\"\ndone\n\n# PR discussion comments\ngh api \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/comments.json\n\n# Linked issues\njq -r '.body // \"\"' /tmp/pr-context/pr.json 2>/dev/null \\\n | grep -oiE '(fixes|closes|resolves)\\s+#[0-9]+' \\\n | grep -oE '[0-9]+$' \\\n | sort -u \\\n | while read -r issue; do\n gh api \"repos/$GITHUB_REPOSITORY/issues/$issue\" > \"/tmp/pr-context/issue-${issue}.json\" || true\n done || true\n\n# Write manifest\ncat > /tmp/pr-context/README.md << 'MANIFEST'\n# PR Context\n\nPre-fetched PR data. All files are in `/tmp/pr-context/`.\n\n| File | Description |\n| --- | --- |\n| `pr.json` | PR metadata — title, body, author, base/head branches, URL |\n| `pr.diff` | Full unified diff of all changes |\n| `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` |\n| `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` |\n| `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body |\n| `review_comments.json` | All review threads (GraphQL) — each thread has `id` (node ID for resolving), `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with `id`, `databaseId` (numeric REST ID for replies), body/author |\n| `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` |\n| `comments.json` | PR discussion comments (not inline) |\n| `issue-{N}.json` | Linked issue details (one file per linked issue, if any) |\n| `agents.md` | Repository conventions from `generate_agents_md` (if written by agent) |\n| `review-instructions.md` | Review instructions, criteria, and calibration examples (if written by review-process fragment) |\nMANIFEST\n\necho \"PR context written to /tmp/pr-context/\"\nls -la /tmp/pr-context/" - name: Write review instructions to disk run: "mkdir -p /tmp/pr-context\ncat > /tmp/pr-context/review-instructions.md << 'REVIEW_EOF'\n# Review Instructions for Sub-agents\n\nYou are a code review sub-agent. Read these instructions, then review the PR files in the order provided in your prompt.\n\n## Context\n\nBefore reviewing files, read these to understand the PR:\n\n1. `/tmp/pr-context/pr.json` — PR title, description, author, and branches. Understand what the PR is trying to accomplish.\n2. `/tmp/pr-context/agents.md` — Repository coding conventions and guidelines (if it exists).\n3. `/tmp/pr-context/review_comments.json` — Existing review threads. Note which files already have threads so you don't duplicate.\n4. `/tmp/pr-context/issue-*.json` — Linked issue details (if any). Understand the motivation and acceptance criteria.\n\n## Process\n\nReview the PR diff file by file in your assigned order. For each changed file:\n\n1. **Read the diff** for this file from `/tmp/pr-context/diffs/.diff` to understand what changed. If the diff is empty or truncated (e.g., binary files or very large changes), fall back to reading the full file from the workspace and comparing against context.\n2. **Read the full file from the workspace.** The PR branch is checked out locally — open the file directly to get complete contents with line numbers.\n3. **Check existing threads** for this file from `/tmp/pr-context/threads/.json` (if it exists). Skip issues that are already under discussion — each thread has `isResolved` and `isOutdated` fields.\n4. **Identify potential issues** matching the review criteria below.\n5. **Quick-check each issue** before including it:\n - What specific code pattern or change triggers this concern?\n - Is there an obvious guard, handler, or mitigation visible in the immediate context?\n - Can you describe a concrete failure scenario (the `evidence` field)? If you cannot articulate what specific input or state triggers the problem, drop the finding.\n - If the issue is clearly handled, skip it. If you're unsure, include it — the parent will verify.\n6. **Add to your findings list.** Do NOT leave inline comments — you don't have that tool. Return findings in this format:\n\n```\n- file: path/to/file\n line: 42\n severity: HIGH\n title: Brief title\n description: What the issue is and why it matters\n evidence: The specific code pattern and failure scenario\n suggestion: corrected code here (optional — only if you can provide a concrete fix)\n```\n\n**Review every file in your assigned order.** Files reviewed earlier get more attention, which is why different sub-agents use different orderings.\n\n**Check existing threads** — per-file threads are at `/tmp/pr-context/threads/.json` (step 3 above). The full list is at `/tmp/pr-context/review_comments.json`. Do not flag issues that are already under discussion (resolved or unresolved). For outdated threads, only re-flag if the issue still applies to the current diff.\n\n**Return your full findings list** when done, or an empty list if no issues were found.\n\n## Review Criteria\n\nFocus on these categories in priority order:\n\n1. Security vulnerabilities (injection, XSS, auth bypass, secrets exposure)\n2. Logic bugs that could cause runtime failures or incorrect behavior\n3. Data integrity issues (race conditions, missing transactions, corruption risk)\n4. Performance bottlenecks (N+1 queries, memory leaks, blocking operations)\n5. Error handling gaps (unhandled exceptions, missing validation)\n6. Breaking changes to public APIs without migration path\n7. Missing or incorrect test coverage for critical paths\n\n## What NOT to Flag\n\nOnly review the diff — do not flag issues in unchanged code, pre-existing problems not introduced by this PR, or style preferences handled by linters or formatters.\n\n**Common false positives** — these patterns look like issues but usually aren't. Before flagging anything in these categories, confirm the problem is real by reading the surrounding code:\n\n- **Security — input already sanitized:** Don't flag injection or XSS risks when inputs are sanitized upstream, parameterized queries are used, or the framework auto-escapes output.\n- **Null/undefined — guarded elsewhere:** Don't flag potential null dereferences if the value is guaranteed by a type guard, assertion, schema validation, or upstream null check.\n- **Error handling — handled at a different layer:** Don't flag missing try/catch if the caller, middleware, or framework catches and handles the error (e.g., Express error middleware, React error boundaries).\n- **Performance — theoretical, not practical:** Don't flag algorithmic complexity (e.g., O(n^2)) unless N is demonstrably large enough to matter in the actual usage context. \"This could be slow\" without evidence is not actionable.\n- **Validation — exists at another layer:** Don't flag missing input validation if it's handled by an API gateway, middleware, schema validator, or type system.\n- **Test coverage — trivial or generated code:** Don't flag missing tests for trivial getters/setters, auto-generated code, or simple delegation methods.\n- **Style or naming — not in coding guidelines:** Don't flag naming conventions or code style unless they violate the repository's documented coding guidelines (from `generate_agents_md` or CONTRIBUTING docs).\n\n**Existing review threads** — check BEFORE flagging any issue:\n\n- **Resolved with reviewer reply** (e.g. \"This is intentional\") — reviewer's decision is final. Do NOT re-flag.\n- **Resolved without reply** — author likely fixed it. Do NOT re-raise unless the fix introduced a new problem.\n- **Unresolved** — already flagged. Do NOT duplicate.\n- **Outdated** — only re-flag if the issue still applies to the current diff.\n\nWhen in doubt, do not duplicate. Redundant comments erode trust.\n\nFinding no issues is a valid and valuable outcome. An empty findings list is better than findings that waste the author's time or erode trust. Do not manufacture findings to justify your review — if the code is sound, return an empty list.\n\n## Severity Classification\n\nDetermine severity AFTER investigating the issue, not before. First identify the problem and trace through the code, then assign a severity based on the evidence you found.\n\n- 🔴 CRITICAL — Must fix before merge (security vulnerabilities, data corruption, production-breaking bugs)\n- 🟠 HIGH — Should fix before merge (logic errors, missing validation, significant performance issues)\n- 🟡 MEDIUM — Address soon, non-blocking (error handling gaps, suboptimal patterns, missing edge cases)\n- ⚪ LOW — Author discretion (minor improvements, documentation gaps)\n- 💬 NITPICK — Truly optional (stylistic preferences, alternative approaches)\n\n## Review Intensity\n\nThe review intensity is `${{ inputs.intensity || 'balanced' }}`.\n\n- **conservative**: High evidence bar. Only flag when you can demonstrate a concrete failure scenario. If you can construct a reasonable counterargument, do not flag. Approval with zero findings is the expected outcome for most PRs.\n- **balanced**: Standard evidence bar. Flag when you can point to specific code that would fail. If the issue is ambiguous, lean toward not flagging.\n- **aggressive**: Lower evidence bar. Flag when evidence exists even if the failure scenario is not fully confirmed. Improvement suggestions welcome but must cite specific code.\n\n## Calibration Examples\n\nUse these examples to calibrate your judgment. Each pair shows a real issue and a similar-looking pattern that is NOT an issue.\n\n### Example 1: Null/Undefined Access\n\n**True positive — flag this:**\n\n```js\n// PR adds this handler\napp.get('/user/:id', async (req, res) => {\n const user = await db.findUser(req.params.id);\n res.json({ name: user.name, email: user.email });\n});\n```\n\nWhy flag: `db.findUser()` can return `null` when no user matches the ID. Accessing `user.name` will throw a TypeError at runtime. No upstream guard exists — the route handler is the entry point.\n\n**False positive — do NOT flag this:**\n\n```ts\n// PR adds this line inside an existing function\nconst settings = user.getSettings();\n```\n\nWhy skip: Reading the full file reveals `user` is typed as `User` (not `User | null`), and the calling function only runs after `authenticateUser()` middleware which guarantees a valid user object. The null case is handled at a different layer.\n\n### Example 2: SQL Injection\n\n**True positive — flag this:**\n\n```python\n# PR adds this query\ncursor.execute(f\"SELECT * FROM orders WHERE customer_id = '{customer_id}'\")\n```\n\nWhy flag: String interpolation in a SQL query with user-controlled input (`customer_id` comes from the request). No parameterization or sanitization anywhere in the call chain.\n\n**False positive — do NOT flag this:**\n\n```python\n# PR adds this query\ncursor.execute(f\"SELECT * FROM orders WHERE status = '{OrderStatus.PENDING.value}'\")\n```\n\nWhy skip: The interpolated value is a hardcoded enum constant (`OrderStatus.PENDING`), not user input. There is no injection vector.\n\n### Example 3: Borderline — Do NOT Flag\n\n```go\n// PR adds this function\nfunc processItems(items []Item) []Result {\n results := make([]Result, 0)\n for _, item := range items {\n for _, tag := range item.Tags {\n results = append(results, process(item, tag))\n }\n }\n return results\n}\n```\n\nThis looks like an O(n*m) performance concern. But without evidence that `items` or `Tags` are large in practice, this is speculative. The function processes a bounded dataset (items from a single user request). Do not flag theoretical performance issues without evidence of real-world impact.\nREVIEW_EOF" - env: @@ -907,6 +920,38 @@ jobs: }, "name": "submit_pull_request_review" }, + { + "description": "Reply to an existing review comment on a pull request. Use this to respond to feedback, answer questions, or acknowledge review comments. The comment_id must be the numeric ID of an existing review comment. CONSTRAINTS: Maximum 10 reply/replies can be created.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "The reply text in Markdown format. Provide a clear response to the review comment.", + "type": "string" + }, + "comment_id": { + "description": "The numeric ID of the review comment to reply to (e.g., 42853901 from the comment URL or API response).", + "type": [ + "number", + "string" + ] + }, + "pull_request_number": { + "description": "Pull request number to reply on. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, replies on the PR that triggered this workflow.", + "type": [ + "number", + "string" + ] + } + }, + "required": [ + "comment_id", + "body" + ], + "type": "object" + }, + "name": "reply_to_pull_request_review_comment" + }, { "description": "Resolve a review thread on a pull request. Use this to mark a review conversation as resolved after addressing the feedback. The thread_id must be the node ID of the review thread (e.g., PRRT_kwDO...).", "inputSchema": { @@ -1278,16 +1323,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF @@ -1791,7 +1872,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.docker.com,*.docker.io,*.githubusercontent.com,*.hackage.haskell.org,*.jsr.io,*.pythonhosted.org,*.rvm.io,*.vsblob.vsassets.io,adoptium.net,agents-md-generator.fastmcp.app,anaconda.org,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.nuget.org,api.rubygems.org,api.snapcraft.io,apt.llvm.org,apt.releases.hashicorp.com,archive.apache.org,archive.ubuntu.com,archlinux.org,artifacts.elastic.co,auth.docker.io,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,binstar.org,bitbucket.org,bootstrap.pypa.io,builds.dotnet.microsoft.com,builds.hex.pm,bun.sh,bundler.rubygems.org,cache.ruby-lang.org,cdn.azul.com,cdn.cocoapods.org,cdn.hex.pm,cdn.jsdelivr.net,cdn.playwright.dev,cdn.redhat.com,cdn.sheetjs.com,central.sonatype.com,ci.dot.net,clojars.org,cloud.elastic.co,cocoapods.org,code.jquery.com,codeload.github.com,conda.anaconda.org,conda.binstar.org,cpan.metacpan.org,cpan.org,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,data.jsdelivr.com,dc.services.visualstudio.com,deb.debian.org,deb.nodesource.com,debian.map.fastlydns.net,deno.land,dist.nuget.org,dl-cdn.alpinelinux.org,dl.bintray.com,dl.fedoraproject.org,dl.google.com,dl.k8s.io,dlcdn.apache.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,download.eclipse.org,download.fedoraproject.org,download.java.net,download.opensuse.org,download.oracle.com,download.swift.org,downloads.gradle-dn.com,downloads.haskell.org,ela.st,elastic.co,elastic.dev,elastic.github.io,esm.sh,fastly.hex.pm,files.pythonhosted.org,fonts.googleapis.com,fonts.gstatic.com,gcr.io,ge.jetbrains.com,gems.rubyforge.org,gems.rubyonrails.org,get-ghcup.haskell.org,get.pnpm.io,getcomposer.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,go.dev,golang.org,googleapis.deno.dev,googlechromelabs.github.io,goproxy.io,gradle.org,haskell.org,hex.pm,host.docker.internal,index.crates.io,index.rubygems.org,jcenter.bintray.com,jdk.java.net,jitpack.io,json-schema.org,json.schemastore.org,jsr.io,keyring.debian.org,keyserver.ubuntu.com,kotlin.bintray.com,lfs.github.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,mcr.microsoft.com,metacpan.org,mirror.archlinux.org,mirror.centos.org,mirrors.fedoraproject.org,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.debian.org,packages.jetbrains.team,packages.microsoft.com,packagist.org,pip.pypa.io,pkg.alpinelinux.org,pkg.go.dev,pkg.machengine.org,pkgs.dev.azure.com,pkgs.k8s.io,playwright.download.prss.microsoft.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,production.cloudflare.docker.com,productionresultssa0.blob.core.windows.net,productionresultssa1.blob.core.windows.net,productionresultssa10.blob.core.windows.net,productionresultssa11.blob.core.windows.net,productionresultssa12.blob.core.windows.net,productionresultssa13.blob.core.windows.net,productionresultssa14.blob.core.windows.net,productionresultssa15.blob.core.windows.net,productionresultssa16.blob.core.windows.net,productionresultssa17.blob.core.windows.net,productionresultssa18.blob.core.windows.net,productionresultssa19.blob.core.windows.net,productionresultssa2.blob.core.windows.net,productionresultssa3.blob.core.windows.net,productionresultssa4.blob.core.windows.net,productionresultssa5.blob.core.windows.net,productionresultssa6.blob.core.windows.net,productionresultssa7.blob.core.windows.net,productionresultssa8.blob.core.windows.net,productionresultssa9.blob.core.windows.net,proxy.golang.org,pub.dartlang.org,pub.dev,public-code-search.fastmcp.app,pypi.org,pypi.python.org,quay.io,raw.githubusercontent.com,registry.bower.io,registry.hub.docker.com,registry.npmjs.com,registry.npmjs.org,registry.terraform.io,registry.yarnpkg.com,releases.hashicorp.com,repo.anaconda.com,repo.clojars.org,repo.continuum.io,repo.gradle.org,repo.grails.org,repo.hex.pm,repo.maven.apache.org,repo.packagist.org,repo.scala-sbt.org,repo.spring.io,repo.typesafe.com,repo.yarnpkg.com,repo1.maven.org,rubygems.org,rubygems.pkg.github.com,s.symcb.com,s.symcd.com,scala-ci.typesafe.com,security.debian.org,security.ubuntu.com,services.gradle.org,sh.rustup.rs,skimdb.npmjs.com,static.crates.io,static.rust-lang.org,storage.googleapis.com,sum.golang.org,swift.org,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,vault.centos.org,www.cpan.org,www.elastic.co,www.java.com,www.microsoft.com,www.npmjs.com,www.npmjs.org,yarnpkg.com,yum.releases.hashicorp.com,ziglang.org" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"create_pull_request_review_comment\":{\"max\":\"${{ inputs.create-pull-request-review-comment-max }}\",\"side\":\"RIGHT\"},\"missing_data\":{},\"missing_tool\":{},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max_patch_size\":10240},\"resolve_pull_request_review_thread\":{\"max\":\"${{ inputs.resolve-pull-request-review-thread-max }}\"},\"submit_pull_request_review\":{\"footer\":\"if-body\",\"max\":1}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"create_pull_request_review_comment\":{\"max\":\"${{ inputs.create-pull-request-review-comment-max }}\",\"side\":\"RIGHT\"},\"missing_data\":{},\"missing_tool\":{},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max_patch_size\":10240},\"reply_to_pull_request_review_comment\":{\"max\":10},\"resolve_pull_request_review_thread\":{\"max\":\"${{ inputs.resolve-pull-request-review-thread-max }}\"},\"submit_pull_request_review\":{\"footer\":\"if-body\",\"max\":1}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.EXTRA_COMMIT_GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/gh-aw-mention-in-pr-no-sandbox.md b/.github/workflows/gh-aw-mention-in-pr-no-sandbox.md index 710cc433..554fce99 100644 --- a/.github/workflows/gh-aw-mention-in-pr-no-sandbox.md +++ b/.github/workflows/gh-aw-mention-in-pr-no-sandbox.md @@ -17,6 +17,7 @@ imports: - gh-aw-fragments/safe-output-submit-review.md - gh-aw-fragments/safe-output-push-to-pr.md - gh-aw-fragments/safe-output-resolve-thread.md + - gh-aw-fragments/safe-output-reply-to-review-comment.md - gh-aw-fragments/network-ecosystems.md engine: id: copilot @@ -121,7 +122,7 @@ Assist with pull requests on ${{ github.repository }} — review code, fix issue ## Constraints -- **CAN**: Read files, search code, modify files locally, run tests and commands, leave inline review comments, submit reviews, resolve review threads, push to the PR branch (same-repo only) +- **CAN**: Read files, search code, modify files locally, run tests and commands, leave inline review comments, submit reviews, reply to review threads, resolve review threads, push to the PR branch (same-repo only) - **CANNOT**: Push to fork PR branches, merge PRs, delete branches When pushing changes, the workspace already has the PR branch checked out. Make your changes, commit them locally, then use `push_to_pull_request_branch`. @@ -158,10 +159,13 @@ Based on what's asked, do the appropriate thing: **If asked to fix code or address review feedback:** - Read `/tmp/pr-context/review_comments.json` to see open review threads and understand what needs to be addressed. -- Make the changes in the workspace. +- For each unresolved thread you address: + - Make the code changes in the workspace. + - If the fix isn't obvious from the code change alone, call `reply_to_pull_request_review_comment` with the comment's numeric ID to briefly explain what you changed. + - If you disagree with feedback or it's unclear, call `reply_to_pull_request_review_comment` to explain your reasoning instead of making changes. Do NOT resolve the thread — let the reviewer decide. - Run required repo commands (lint/build/test) from README, CONTRIBUTING, DEVELOPING, Makefile, or CI config relevant to the change and include results. If required commands cannot be run, explain why and do not push changes. - Commit your changes locally, then use `push_to_pull_request_branch` to push them. -- After pushing, resolve each addressed review thread by calling `resolve_pull_request_review_thread` with the thread's node ID (the `id` field from `get_review_comments`, e.g., `PRRT_kwDO...`). Only resolve threads you have actually addressed — do not resolve threads you skipped or disagreed with. +- After pushing, resolve every review thread that your changes address by calling `resolve_pull_request_review_thread` with the thread's GraphQL node ID (the `id` field, e.g., `PRRT_kwDO...`). This includes threads left by other reviewers AND threads from your own prior reviews. Check `/tmp/pr-context/review_comments.json` for all unresolved threads (`isResolved: false`) — `isOutdated` threads have had the underlying code changed since the comment was made, so check whether your changes address them. Do NOT resolve threads you disagreed with, skipped, or only partially addressed — leave those open for the reviewer. - **Fork PRs**: Check via `pull_request_read` with method `get` whether the PR head repo differs from the base repo. If it's a fork, you cannot push — reply explaining that you do not have permission to push to fork branches and suggest that the PR author apply the changes themselves. This is a GitHub security limitation. You can still review code, make local changes, and provide suggestions. **If asked a question about the code:** @@ -178,6 +182,7 @@ If you did not submit a PR review, call `add_comment` with your response. If you **Additional tools:** - `push_to_pull_request_branch` — push committed changes to the PR branch (same-repo PRs only) -- `resolve_pull_request_review_thread` — resolve a review thread after addressing the feedback (pass the thread's node ID) +- `reply_to_pull_request_review_comment` — reply inline to a review comment thread to explain what you changed or why you disagree +- `resolve_pull_request_review_thread` — resolve a review thread after addressing the feedback (pass the thread's GraphQL node ID) ${{ inputs.additional-instructions }} diff --git a/.github/workflows/gh-aw-mention-in-pr.lock.yml b/.github/workflows/gh-aw-mention-in-pr.lock.yml index 4494f59a..e2cc4644 100644 --- a/.github/workflows/gh-aw-mention-in-pr.lock.yml +++ b/.github/workflows/gh-aw-mention-in-pr.lock.yml @@ -36,6 +36,7 @@ # - gh-aw-fragments/runtime-setup.md # - gh-aw-fragments/safe-output-add-comment-pr.md # - gh-aw-fragments/safe-output-push-to-pr.md +# - gh-aw-fragments/safe-output-reply-to-review-comment.md # - gh-aw-fragments/safe-output-resolve-thread.md # - gh-aw-fragments/safe-output-review-comment.md # - gh-aw-fragments/safe-output-submit-review.md @@ -43,7 +44,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"6e713a7a69e5f678fbf04a92c7aaeabd25538499ab5a5d8d113d91529806197e"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"8d20590bf294d888940de1885bc1e17dba9d0e7a58b0808cdac0022034157222"} name: "Mention in PR" "on": @@ -195,7 +196,7 @@ jobs: cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' - Tools: add_comment, create_pull_request_review_comment, submit_pull_request_review, resolve_pull_request_review_thread, push_to_pull_request_branch, missing_tool, missing_data, noop + Tools: add_comment, create_pull_request_review_comment, submit_pull_request_review, reply_to_pull_request_review_comment, resolve_pull_request_review_thread, push_to_pull_request_branch, missing_tool, missing_data, noop GH_AW_PROMPT_EOF cat "/opt/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" cat << 'GH_AW_PROMPT_EOF' @@ -462,6 +463,14 @@ jobs: - **Max per run**: __GH_AW_EXPR_7F2A702A__ thread resolutions per workflow run. GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + ## reply-to-pull-request-review-comment Limitations + + - **Required field**: `comment_id` — the numeric REST comment ID (e.g., `2481734562`). From `get_review_comments` this is the `id` field. From `/tmp/pr-context/review_comments.json` (GraphQL) this is the `databaseId` field. Do not pass GraphQL node IDs (e.g., `IC_kwDONVGiRc6...`) — those will fail. + - **Body**: Max 65,536 characters. Keep well under this limit. + - **Purpose**: Reply directly to a specific review comment thread to explain your reasoning when you disagree with or skip feedback. Do NOT use `add_comment` for this — use this tool to keep replies in context. + - **Max per run**: 10 replies per workflow run. + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' @@ -478,7 +487,7 @@ jobs: ## Constraints - - **CAN**: Read files, search code, modify files locally, run tests and commands, leave inline review comments, submit reviews, resolve review threads, push to the PR branch (same-repo only) + - **CAN**: Read files, search code, modify files locally, run tests and commands, leave inline review comments, submit reviews, reply to review threads, resolve review threads, push to the PR branch (same-repo only) - **CANNOT**: Push to fork PR branches, merge PRs, delete branches When pushing changes, the workspace already has the PR branch checked out. Make your changes, commit them locally, then use `push_to_pull_request_branch`. @@ -515,10 +524,13 @@ jobs: **If asked to fix code or address review feedback:** - Read `/tmp/pr-context/review_comments.json` to see open review threads and understand what needs to be addressed. - - Make the changes in the workspace. + - For each unresolved thread you address: + - Make the code changes in the workspace. + - If the fix isn't obvious from the code change alone, call `reply_to_pull_request_review_comment` with the comment's numeric ID to briefly explain what you changed. + - If you disagree with feedback or it's unclear, call `reply_to_pull_request_review_comment` to explain your reasoning instead of making changes. Do NOT resolve the thread — let the reviewer decide. - Run required repo commands (lint/build/test) from README, CONTRIBUTING, DEVELOPING, Makefile, or CI config relevant to the change and include results. If required commands cannot be run, explain why and do not push changes. - Commit your changes locally, then use `push_to_pull_request_branch` to push them. - - After pushing, resolve each addressed review thread by calling `resolve_pull_request_review_thread` with the thread's node ID (the `id` field from `get_review_comments`, e.g., `PRRT_kwDO...`). Only resolve threads you have actually addressed — do not resolve threads you skipped or disagreed with. + - After pushing, resolve every review thread that your changes address by calling `resolve_pull_request_review_thread` with the thread's GraphQL node ID (the `id` field, e.g., `PRRT_kwDO...`). This includes threads left by other reviewers AND threads from your own prior reviews. Check `/tmp/pr-context/review_comments.json` for all unresolved threads (`isResolved: false`) — `isOutdated` threads have had the underlying code changed since the comment was made, so check whether your changes address them. Do NOT resolve threads you disagreed with, skipped, or only partially addressed — leave those open for the reviewer. - **Fork PRs**: Check via `pull_request_read` with method `get` whether the PR head repo differs from the base repo. If it's a fork, you cannot push — reply explaining that you do not have permission to push to fork branches and suggest that the PR author apply the changes themselves. This is a GitHub security limitation. You can still review code, make local changes, and provide suggestions. **If asked a question about the code:** @@ -535,7 +547,8 @@ jobs: **Additional tools:** - `push_to_pull_request_branch` — push committed changes to the PR branch (same-repo PRs only) - - `resolve_pull_request_review_thread` — resolve a review thread after addressing the feedback (pass the thread's node ID) + - `reply_to_pull_request_review_comment` — reply inline to a review comment thread to explain what you changed or why you disagree + - `resolve_pull_request_review_thread` — resolve a review thread after addressing the feedback (pass the thread's GraphQL node ID) __GH_AW_EXPR_49B959F1__ @@ -711,7 +724,7 @@ jobs: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number || inputs.target-pr-number || github.event.issue.number }} name: Fetch PR context to disk - run: "set -euo pipefail\nmkdir -p /tmp/pr-context\n\n# PR metadata\ngh pr view \"$PR_NUMBER\" --json title,body,author,baseRefName,headRefName,url \\\n > /tmp/pr-context/pr.json\n\n# Full diff\nif ! gh pr diff \"$PR_NUMBER\" > /tmp/pr-context/pr.diff; then\n echo \"::warning::Failed to fetch full PR diff; per-file diffs from files.json are still available.\"\n : > /tmp/pr-context/pr.diff\nfi\n\n# Changed files list (--paginate may output concatenated arrays; jq -s 'add' merges them)\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/files.json\n\n# Per-file diffs\njq -c '.[]' /tmp/pr-context/files.json | while IFS= read -r entry; do\n filename=$(echo \"$entry\" | jq -r '.filename')\n mkdir -p \"/tmp/pr-context/diffs/$(dirname \"$filename\")\"\n echo \"$entry\" | jq -r '.patch // empty' > \"/tmp/pr-context/diffs/${filename}.diff\"\ndone\n\n# Existing reviews\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/reviews.json\n\n# Review threads with resolution status (GraphQL — REST lacks isResolved/isOutdated)\ngh api graphql --paginate -f query='\n query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n reviewThreads(first: 100, after: $endCursor) {\n pageInfo { hasNextPage endCursor }\n nodes {\n id\n isResolved\n isOutdated\n isCollapsed\n path\n line\n startLine\n comments(first: 100) {\n nodes {\n id\n body\n author { login }\n createdAt\n }\n }\n }\n }\n }\n }\n }\n' -F owner=\"${GITHUB_REPOSITORY%/*}\" -F repo=\"${GITHUB_REPOSITORY#*/}\" -F \"number=$PR_NUMBER\" \\\n --jq '.data.repository.pullRequest.reviewThreads.nodes' \\\n | jq -s 'add // []' > /tmp/pr-context/review_comments.json\n\n# Per-file review threads (mirrors diffs/ structure)\njq -c '.[]' /tmp/pr-context/review_comments.json | while IFS= read -r thread; do\n filepath=$(echo \"$thread\" | jq -r '.path // empty')\n [ -z \"$filepath\" ] && continue\n mkdir -p \"/tmp/pr-context/threads/$(dirname \"$filepath\")\"\n echo \"$thread\" >> \"/tmp/pr-context/threads/${filepath}.jsonl\"\ndone\n# Convert per-file JSONL to proper JSON arrays\nmkdir -p /tmp/pr-context/threads\nfind /tmp/pr-context/threads -name '*.jsonl' 2>/dev/null | while IFS= read -r jsonl; do\n jq -s '.' \"$jsonl\" > \"${jsonl%.jsonl}.json\"\n rm \"$jsonl\"\ndone\n\n# PR discussion comments\ngh api \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/comments.json\n\n# Linked issues\njq -r '.body // \"\"' /tmp/pr-context/pr.json 2>/dev/null \\\n | grep -oiE '(fixes|closes|resolves)\\s+#[0-9]+' \\\n | grep -oE '[0-9]+$' \\\n | sort -u \\\n | while read -r issue; do\n gh api \"repos/$GITHUB_REPOSITORY/issues/$issue\" > \"/tmp/pr-context/issue-${issue}.json\" || true\n done || true\n\n# Write manifest\ncat > /tmp/pr-context/README.md << 'MANIFEST'\n# PR Context\n\nPre-fetched PR data. All files are in `/tmp/pr-context/`.\n\n| File | Description |\n| --- | --- |\n| `pr.json` | PR metadata — title, body, author, base/head branches, URL |\n| `pr.diff` | Full unified diff of all changes |\n| `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` |\n| `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` |\n| `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body |\n| `review_comments.json` | All review threads (GraphQL) — each thread has `id`, `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with body/author |\n| `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` |\n| `comments.json` | PR discussion comments (not inline) |\n| `issue-{N}.json` | Linked issue details (one file per linked issue, if any) |\n| `agents.md` | Repository conventions from `generate_agents_md` (if written by agent) |\n| `review-instructions.md` | Review instructions, criteria, and calibration examples (if written by review-process fragment) |\nMANIFEST\n\necho \"PR context written to /tmp/pr-context/\"\nls -la /tmp/pr-context/" + run: "set -euo pipefail\nmkdir -p /tmp/pr-context\n\n# PR metadata\ngh pr view \"$PR_NUMBER\" --json title,body,author,baseRefName,headRefName,url \\\n > /tmp/pr-context/pr.json\n\n# Full diff\nif ! gh pr diff \"$PR_NUMBER\" > /tmp/pr-context/pr.diff; then\n echo \"::warning::Failed to fetch full PR diff; per-file diffs from files.json are still available.\"\n : > /tmp/pr-context/pr.diff\nfi\n\n# Changed files list (--paginate may output concatenated arrays; jq -s 'add' merges them)\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/files.json\n\n# Per-file diffs\njq -c '.[]' /tmp/pr-context/files.json | while IFS= read -r entry; do\n filename=$(echo \"$entry\" | jq -r '.filename')\n mkdir -p \"/tmp/pr-context/diffs/$(dirname \"$filename\")\"\n echo \"$entry\" | jq -r '.patch // empty' > \"/tmp/pr-context/diffs/${filename}.diff\"\ndone\n\n# Existing reviews\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/reviews.json\n\n# Review threads with resolution status (GraphQL — REST lacks isResolved/isOutdated)\ngh api graphql --paginate -f query='\n query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n reviewThreads(first: 100, after: $endCursor) {\n pageInfo { hasNextPage endCursor }\n nodes {\n id\n isResolved\n isOutdated\n isCollapsed\n path\n line\n startLine\n comments(first: 100) {\n nodes {\n id\n databaseId\n body\n author { login }\n createdAt\n }\n }\n }\n }\n }\n }\n }\n' -F owner=\"${GITHUB_REPOSITORY%/*}\" -F repo=\"${GITHUB_REPOSITORY#*/}\" -F \"number=$PR_NUMBER\" \\\n --jq '.data.repository.pullRequest.reviewThreads.nodes' \\\n | jq -s 'add // []' > /tmp/pr-context/review_comments.json\n\n# Per-file review threads (mirrors diffs/ structure)\njq -c '.[]' /tmp/pr-context/review_comments.json | while IFS= read -r thread; do\n filepath=$(echo \"$thread\" | jq -r '.path // empty')\n [ -z \"$filepath\" ] && continue\n mkdir -p \"/tmp/pr-context/threads/$(dirname \"$filepath\")\"\n echo \"$thread\" >> \"/tmp/pr-context/threads/${filepath}.jsonl\"\ndone\n# Convert per-file JSONL to proper JSON arrays\nmkdir -p /tmp/pr-context/threads\nfind /tmp/pr-context/threads -name '*.jsonl' 2>/dev/null | while IFS= read -r jsonl; do\n jq -s '.' \"$jsonl\" > \"${jsonl%.jsonl}.json\"\n rm \"$jsonl\"\ndone\n\n# PR discussion comments\ngh api \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/comments.json\n\n# Linked issues\njq -r '.body // \"\"' /tmp/pr-context/pr.json 2>/dev/null \\\n | grep -oiE '(fixes|closes|resolves)\\s+#[0-9]+' \\\n | grep -oE '[0-9]+$' \\\n | sort -u \\\n | while read -r issue; do\n gh api \"repos/$GITHUB_REPOSITORY/issues/$issue\" > \"/tmp/pr-context/issue-${issue}.json\" || true\n done || true\n\n# Write manifest\ncat > /tmp/pr-context/README.md << 'MANIFEST'\n# PR Context\n\nPre-fetched PR data. All files are in `/tmp/pr-context/`.\n\n| File | Description |\n| --- | --- |\n| `pr.json` | PR metadata — title, body, author, base/head branches, URL |\n| `pr.diff` | Full unified diff of all changes |\n| `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` |\n| `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` |\n| `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body |\n| `review_comments.json` | All review threads (GraphQL) — each thread has `id` (node ID for resolving), `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with `id`, `databaseId` (numeric REST ID for replies), body/author |\n| `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` |\n| `comments.json` | PR discussion comments (not inline) |\n| `issue-{N}.json` | Linked issue details (one file per linked issue, if any) |\n| `agents.md` | Repository conventions from `generate_agents_md` (if written by agent) |\n| `review-instructions.md` | Review instructions, criteria, and calibration examples (if written by review-process fragment) |\nMANIFEST\n\necho \"PR context written to /tmp/pr-context/\"\nls -la /tmp/pr-context/" - name: Write review instructions to disk run: "mkdir -p /tmp/pr-context\ncat > /tmp/pr-context/review-instructions.md << 'REVIEW_EOF'\n# Review Instructions for Sub-agents\n\nYou are a code review sub-agent. Read these instructions, then review the PR files in the order provided in your prompt.\n\n## Context\n\nBefore reviewing files, read these to understand the PR:\n\n1. `/tmp/pr-context/pr.json` — PR title, description, author, and branches. Understand what the PR is trying to accomplish.\n2. `/tmp/pr-context/agents.md` — Repository coding conventions and guidelines (if it exists).\n3. `/tmp/pr-context/review_comments.json` — Existing review threads. Note which files already have threads so you don't duplicate.\n4. `/tmp/pr-context/issue-*.json` — Linked issue details (if any). Understand the motivation and acceptance criteria.\n\n## Process\n\nReview the PR diff file by file in your assigned order. For each changed file:\n\n1. **Read the diff** for this file from `/tmp/pr-context/diffs/.diff` to understand what changed. If the diff is empty or truncated (e.g., binary files or very large changes), fall back to reading the full file from the workspace and comparing against context.\n2. **Read the full file from the workspace.** The PR branch is checked out locally — open the file directly to get complete contents with line numbers.\n3. **Check existing threads** for this file from `/tmp/pr-context/threads/.json` (if it exists). Skip issues that are already under discussion — each thread has `isResolved` and `isOutdated` fields.\n4. **Identify potential issues** matching the review criteria below.\n5. **Quick-check each issue** before including it:\n - What specific code pattern or change triggers this concern?\n - Is there an obvious guard, handler, or mitigation visible in the immediate context?\n - Can you describe a concrete failure scenario (the `evidence` field)? If you cannot articulate what specific input or state triggers the problem, drop the finding.\n - If the issue is clearly handled, skip it. If you're unsure, include it — the parent will verify.\n6. **Add to your findings list.** Do NOT leave inline comments — you don't have that tool. Return findings in this format:\n\n```\n- file: path/to/file\n line: 42\n severity: HIGH\n title: Brief title\n description: What the issue is and why it matters\n evidence: The specific code pattern and failure scenario\n suggestion: corrected code here (optional — only if you can provide a concrete fix)\n```\n\n**Review every file in your assigned order.** Files reviewed earlier get more attention, which is why different sub-agents use different orderings.\n\n**Check existing threads** — per-file threads are at `/tmp/pr-context/threads/.json` (step 3 above). The full list is at `/tmp/pr-context/review_comments.json`. Do not flag issues that are already under discussion (resolved or unresolved). For outdated threads, only re-flag if the issue still applies to the current diff.\n\n**Return your full findings list** when done, or an empty list if no issues were found.\n\n## Review Criteria\n\nFocus on these categories in priority order:\n\n1. Security vulnerabilities (injection, XSS, auth bypass, secrets exposure)\n2. Logic bugs that could cause runtime failures or incorrect behavior\n3. Data integrity issues (race conditions, missing transactions, corruption risk)\n4. Performance bottlenecks (N+1 queries, memory leaks, blocking operations)\n5. Error handling gaps (unhandled exceptions, missing validation)\n6. Breaking changes to public APIs without migration path\n7. Missing or incorrect test coverage for critical paths\n\n## What NOT to Flag\n\nOnly review the diff — do not flag issues in unchanged code, pre-existing problems not introduced by this PR, or style preferences handled by linters or formatters.\n\n**Common false positives** — these patterns look like issues but usually aren't. Before flagging anything in these categories, confirm the problem is real by reading the surrounding code:\n\n- **Security — input already sanitized:** Don't flag injection or XSS risks when inputs are sanitized upstream, parameterized queries are used, or the framework auto-escapes output.\n- **Null/undefined — guarded elsewhere:** Don't flag potential null dereferences if the value is guaranteed by a type guard, assertion, schema validation, or upstream null check.\n- **Error handling — handled at a different layer:** Don't flag missing try/catch if the caller, middleware, or framework catches and handles the error (e.g., Express error middleware, React error boundaries).\n- **Performance — theoretical, not practical:** Don't flag algorithmic complexity (e.g., O(n^2)) unless N is demonstrably large enough to matter in the actual usage context. \"This could be slow\" without evidence is not actionable.\n- **Validation — exists at another layer:** Don't flag missing input validation if it's handled by an API gateway, middleware, schema validator, or type system.\n- **Test coverage — trivial or generated code:** Don't flag missing tests for trivial getters/setters, auto-generated code, or simple delegation methods.\n- **Style or naming — not in coding guidelines:** Don't flag naming conventions or code style unless they violate the repository's documented coding guidelines (from `generate_agents_md` or CONTRIBUTING docs).\n\n**Existing review threads** — check BEFORE flagging any issue:\n\n- **Resolved with reviewer reply** (e.g. \"This is intentional\") — reviewer's decision is final. Do NOT re-flag.\n- **Resolved without reply** — author likely fixed it. Do NOT re-raise unless the fix introduced a new problem.\n- **Unresolved** — already flagged. Do NOT duplicate.\n- **Outdated** — only re-flag if the issue still applies to the current diff.\n\nWhen in doubt, do not duplicate. Redundant comments erode trust.\n\nFinding no issues is a valid and valuable outcome. An empty findings list is better than findings that waste the author's time or erode trust. Do not manufacture findings to justify your review — if the code is sound, return an empty list.\n\n## Severity Classification\n\nDetermine severity AFTER investigating the issue, not before. First identify the problem and trace through the code, then assign a severity based on the evidence you found.\n\n- 🔴 CRITICAL — Must fix before merge (security vulnerabilities, data corruption, production-breaking bugs)\n- 🟠 HIGH — Should fix before merge (logic errors, missing validation, significant performance issues)\n- 🟡 MEDIUM — Address soon, non-blocking (error handling gaps, suboptimal patterns, missing edge cases)\n- ⚪ LOW — Author discretion (minor improvements, documentation gaps)\n- 💬 NITPICK — Truly optional (stylistic preferences, alternative approaches)\n\n## Review Intensity\n\nThe review intensity is `${{ inputs.intensity || 'balanced' }}`.\n\n- **conservative**: High evidence bar. Only flag when you can demonstrate a concrete failure scenario. If you can construct a reasonable counterargument, do not flag. Approval with zero findings is the expected outcome for most PRs.\n- **balanced**: Standard evidence bar. Flag when you can point to specific code that would fail. If the issue is ambiguous, lean toward not flagging.\n- **aggressive**: Lower evidence bar. Flag when evidence exists even if the failure scenario is not fully confirmed. Improvement suggestions welcome but must cite specific code.\n\n## Calibration Examples\n\nUse these examples to calibrate your judgment. Each pair shows a real issue and a similar-looking pattern that is NOT an issue.\n\n### Example 1: Null/Undefined Access\n\n**True positive — flag this:**\n\n```js\n// PR adds this handler\napp.get('/user/:id', async (req, res) => {\n const user = await db.findUser(req.params.id);\n res.json({ name: user.name, email: user.email });\n});\n```\n\nWhy flag: `db.findUser()` can return `null` when no user matches the ID. Accessing `user.name` will throw a TypeError at runtime. No upstream guard exists — the route handler is the entry point.\n\n**False positive — do NOT flag this:**\n\n```ts\n// PR adds this line inside an existing function\nconst settings = user.getSettings();\n```\n\nWhy skip: Reading the full file reveals `user` is typed as `User` (not `User | null`), and the calling function only runs after `authenticateUser()` middleware which guarantees a valid user object. The null case is handled at a different layer.\n\n### Example 2: SQL Injection\n\n**True positive — flag this:**\n\n```python\n# PR adds this query\ncursor.execute(f\"SELECT * FROM orders WHERE customer_id = '{customer_id}'\")\n```\n\nWhy flag: String interpolation in a SQL query with user-controlled input (`customer_id` comes from the request). No parameterization or sanitization anywhere in the call chain.\n\n**False positive — do NOT flag this:**\n\n```python\n# PR adds this query\ncursor.execute(f\"SELECT * FROM orders WHERE status = '{OrderStatus.PENDING.value}'\")\n```\n\nWhy skip: The interpolated value is a hardcoded enum constant (`OrderStatus.PENDING`), not user input. There is no injection vector.\n\n### Example 3: Borderline — Do NOT Flag\n\n```go\n// PR adds this function\nfunc processItems(items []Item) []Result {\n results := make([]Result, 0)\n for _, item := range items {\n for _, tag := range item.Tags {\n results = append(results, process(item, tag))\n }\n }\n return results\n}\n```\n\nThis looks like an O(n*m) performance concern. But without evidence that `items` or `Tags` are large in practice, this is speculative. The function processes a bounded dataset (items from a single user request). Do not flag theoretical performance issues without evidence of real-world impact.\nREVIEW_EOF" - env: @@ -910,6 +923,38 @@ jobs: }, "name": "submit_pull_request_review" }, + { + "description": "Reply to an existing review comment on a pull request. Use this to respond to feedback, answer questions, or acknowledge review comments. The comment_id must be the numeric ID of an existing review comment. CONSTRAINTS: Maximum 10 reply/replies can be created.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "The reply text in Markdown format. Provide a clear response to the review comment.", + "type": "string" + }, + "comment_id": { + "description": "The numeric ID of the review comment to reply to (e.g., 42853901 from the comment URL or API response).", + "type": [ + "number", + "string" + ] + }, + "pull_request_number": { + "description": "Pull request number to reply on. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, replies on the PR that triggered this workflow.", + "type": [ + "number", + "string" + ] + } + }, + "required": [ + "comment_id", + "body" + ], + "type": "object" + }, + "name": "reply_to_pull_request_review_comment" + }, { "description": "Resolve a review thread on a pull request. Use this to mark a review conversation as resolved after addressing the feedback. The thread_id must be the node ID of the review thread (e.g., PRRT_kwDO...).", "inputSchema": { @@ -1281,16 +1326,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF @@ -1923,7 +2004,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.docker.com,*.docker.io,*.githubusercontent.com,*.hackage.haskell.org,*.jsr.io,*.pythonhosted.org,*.rvm.io,*.vsblob.vsassets.io,adoptium.net,agents-md-generator.fastmcp.app,anaconda.org,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.nuget.org,api.rubygems.org,api.snapcraft.io,apt.llvm.org,apt.releases.hashicorp.com,archive.apache.org,archive.ubuntu.com,archlinux.org,artifacts.elastic.co,auth.docker.io,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,binstar.org,bitbucket.org,bootstrap.pypa.io,builds.dotnet.microsoft.com,builds.hex.pm,bun.sh,bundler.rubygems.org,cache.ruby-lang.org,cdn.azul.com,cdn.cocoapods.org,cdn.hex.pm,cdn.jsdelivr.net,cdn.playwright.dev,cdn.redhat.com,cdn.sheetjs.com,central.sonatype.com,ci.dot.net,clojars.org,cloud.elastic.co,cocoapods.org,code.jquery.com,codeload.github.com,conda.anaconda.org,conda.binstar.org,cpan.metacpan.org,cpan.org,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,data.jsdelivr.com,dc.services.visualstudio.com,deb.debian.org,deb.nodesource.com,debian.map.fastlydns.net,deno.land,dist.nuget.org,dl-cdn.alpinelinux.org,dl.bintray.com,dl.fedoraproject.org,dl.google.com,dl.k8s.io,dlcdn.apache.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,download.eclipse.org,download.fedoraproject.org,download.java.net,download.opensuse.org,download.oracle.com,download.swift.org,downloads.gradle-dn.com,downloads.haskell.org,ela.st,elastic.co,elastic.dev,elastic.github.io,esm.sh,fastly.hex.pm,files.pythonhosted.org,fonts.googleapis.com,fonts.gstatic.com,gcr.io,ge.jetbrains.com,gems.rubyforge.org,gems.rubyonrails.org,get-ghcup.haskell.org,get.pnpm.io,getcomposer.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,go.dev,golang.org,googleapis.deno.dev,googlechromelabs.github.io,goproxy.io,gradle.org,haskell.org,hex.pm,host.docker.internal,index.crates.io,index.rubygems.org,jcenter.bintray.com,jdk.java.net,jitpack.io,json-schema.org,json.schemastore.org,jsr.io,keyring.debian.org,keyserver.ubuntu.com,kotlin.bintray.com,lfs.github.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,mcr.microsoft.com,metacpan.org,mirror.archlinux.org,mirror.centos.org,mirrors.fedoraproject.org,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.debian.org,packages.jetbrains.team,packages.microsoft.com,packagist.org,pip.pypa.io,pkg.alpinelinux.org,pkg.go.dev,pkg.machengine.org,pkgs.dev.azure.com,pkgs.k8s.io,playwright.download.prss.microsoft.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,production.cloudflare.docker.com,productionresultssa0.blob.core.windows.net,productionresultssa1.blob.core.windows.net,productionresultssa10.blob.core.windows.net,productionresultssa11.blob.core.windows.net,productionresultssa12.blob.core.windows.net,productionresultssa13.blob.core.windows.net,productionresultssa14.blob.core.windows.net,productionresultssa15.blob.core.windows.net,productionresultssa16.blob.core.windows.net,productionresultssa17.blob.core.windows.net,productionresultssa18.blob.core.windows.net,productionresultssa19.blob.core.windows.net,productionresultssa2.blob.core.windows.net,productionresultssa3.blob.core.windows.net,productionresultssa4.blob.core.windows.net,productionresultssa5.blob.core.windows.net,productionresultssa6.blob.core.windows.net,productionresultssa7.blob.core.windows.net,productionresultssa8.blob.core.windows.net,productionresultssa9.blob.core.windows.net,proxy.golang.org,pub.dartlang.org,pub.dev,public-code-search.fastmcp.app,pypi.org,pypi.python.org,quay.io,raw.githubusercontent.com,registry.bower.io,registry.hub.docker.com,registry.npmjs.com,registry.npmjs.org,registry.terraform.io,registry.yarnpkg.com,releases.hashicorp.com,repo.anaconda.com,repo.clojars.org,repo.continuum.io,repo.gradle.org,repo.grails.org,repo.hex.pm,repo.maven.apache.org,repo.packagist.org,repo.scala-sbt.org,repo.spring.io,repo.typesafe.com,repo.yarnpkg.com,repo1.maven.org,rubygems.org,rubygems.pkg.github.com,s.symcb.com,s.symcd.com,scala-ci.typesafe.com,security.debian.org,security.ubuntu.com,services.gradle.org,sh.rustup.rs,skimdb.npmjs.com,static.crates.io,static.rust-lang.org,storage.googleapis.com,sum.golang.org,swift.org,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,vault.centos.org,www.cpan.org,www.elastic.co,www.java.com,www.microsoft.com,www.npmjs.com,www.npmjs.org,yarnpkg.com,yum.releases.hashicorp.com,ziglang.org" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"create_pull_request_review_comment\":{\"max\":\"${{ inputs.create-pull-request-review-comment-max }}\",\"side\":\"RIGHT\"},\"missing_data\":{},\"missing_tool\":{},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max_patch_size\":10240},\"resolve_pull_request_review_thread\":{\"max\":\"${{ inputs.resolve-pull-request-review-thread-max }}\"},\"submit_pull_request_review\":{\"footer\":\"if-body\",\"max\":1}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"create_pull_request_review_comment\":{\"max\":\"${{ inputs.create-pull-request-review-comment-max }}\",\"side\":\"RIGHT\"},\"missing_data\":{},\"missing_tool\":{},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max_patch_size\":10240},\"reply_to_pull_request_review_comment\":{\"max\":10},\"resolve_pull_request_review_thread\":{\"max\":\"${{ inputs.resolve-pull-request-review-thread-max }}\"},\"submit_pull_request_review\":{\"footer\":\"if-body\",\"max\":1}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.EXTRA_COMMIT_GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/gh-aw-mention-in-pr.md b/.github/workflows/gh-aw-mention-in-pr.md index a668c337..a00f6f41 100644 --- a/.github/workflows/gh-aw-mention-in-pr.md +++ b/.github/workflows/gh-aw-mention-in-pr.md @@ -17,6 +17,7 @@ imports: - gh-aw-fragments/safe-output-submit-review.md - gh-aw-fragments/safe-output-push-to-pr.md - gh-aw-fragments/safe-output-resolve-thread.md + - gh-aw-fragments/safe-output-reply-to-review-comment.md - gh-aw-fragments/network-ecosystems.md engine: id: copilot @@ -128,7 +129,7 @@ Assist with pull requests on ${{ github.repository }} — review code, fix issue ## Constraints -- **CAN**: Read files, search code, modify files locally, run tests and commands, leave inline review comments, submit reviews, resolve review threads, push to the PR branch (same-repo only) +- **CAN**: Read files, search code, modify files locally, run tests and commands, leave inline review comments, submit reviews, reply to review threads, resolve review threads, push to the PR branch (same-repo only) - **CANNOT**: Push to fork PR branches, merge PRs, delete branches When pushing changes, the workspace already has the PR branch checked out. Make your changes, commit them locally, then use `push_to_pull_request_branch`. @@ -165,10 +166,13 @@ Based on what's asked, do the appropriate thing: **If asked to fix code or address review feedback:** - Read `/tmp/pr-context/review_comments.json` to see open review threads and understand what needs to be addressed. -- Make the changes in the workspace. +- For each unresolved thread you address: + - Make the code changes in the workspace. + - If the fix isn't obvious from the code change alone, call `reply_to_pull_request_review_comment` with the comment's numeric ID to briefly explain what you changed. + - If you disagree with feedback or it's unclear, call `reply_to_pull_request_review_comment` to explain your reasoning instead of making changes. Do NOT resolve the thread — let the reviewer decide. - Run required repo commands (lint/build/test) from README, CONTRIBUTING, DEVELOPING, Makefile, or CI config relevant to the change and include results. If required commands cannot be run, explain why and do not push changes. - Commit your changes locally, then use `push_to_pull_request_branch` to push them. -- After pushing, resolve each addressed review thread by calling `resolve_pull_request_review_thread` with the thread's node ID (the `id` field from `get_review_comments`, e.g., `PRRT_kwDO...`). Only resolve threads you have actually addressed — do not resolve threads you skipped or disagreed with. +- After pushing, resolve every review thread that your changes address by calling `resolve_pull_request_review_thread` with the thread's GraphQL node ID (the `id` field, e.g., `PRRT_kwDO...`). This includes threads left by other reviewers AND threads from your own prior reviews. Check `/tmp/pr-context/review_comments.json` for all unresolved threads (`isResolved: false`) — `isOutdated` threads have had the underlying code changed since the comment was made, so check whether your changes address them. Do NOT resolve threads you disagreed with, skipped, or only partially addressed — leave those open for the reviewer. - **Fork PRs**: Check via `pull_request_read` with method `get` whether the PR head repo differs from the base repo. If it's a fork, you cannot push — reply explaining that you do not have permission to push to fork branches and suggest that the PR author apply the changes themselves. This is a GitHub security limitation. You can still review code, make local changes, and provide suggestions. **If asked a question about the code:** @@ -185,6 +189,7 @@ If you did not submit a PR review, call `add_comment` with your response. If you **Additional tools:** - `push_to_pull_request_branch` — push committed changes to the PR branch (same-repo PRs only) -- `resolve_pull_request_review_thread` — resolve a review thread after addressing the feedback (pass the thread's node ID) +- `reply_to_pull_request_review_comment` — reply inline to a review comment thread to explain what you changed or why you disagree +- `resolve_pull_request_review_thread` — resolve a review thread after addressing the feedback (pass the thread's GraphQL node ID) ${{ inputs.additional-instructions }} diff --git a/.github/workflows/gh-aw-newbie-contributor-fixer.lock.yml b/.github/workflows/gh-aw-newbie-contributor-fixer.lock.yml index 3d3ee173..6bd4ea24 100644 --- a/.github/workflows/gh-aw-newbie-contributor-fixer.lock.yml +++ b/.github/workflows/gh-aw-newbie-contributor-fixer.lock.yml @@ -37,7 +37,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"9d3d805c19f223fef9cadc064cd9e781df2ce4ae7a32b24d151de1086b62e168"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"73718d7f4a6dc547a830f19821394962de7b454fb94c06a0a0fcd1690c7701b6"} name: "Newbie Contributor Fixer" "on": @@ -924,16 +924,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-pr-actions-fixer.lock.yml b/.github/workflows/gh-aw-pr-actions-fixer.lock.yml index 7cab17af..f8461cf1 100644 --- a/.github/workflows/gh-aw-pr-actions-fixer.lock.yml +++ b/.github/workflows/gh-aw-pr-actions-fixer.lock.yml @@ -37,7 +37,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"70e2654ad8f59cff4dee6d213bffb0b066f53e7560efab02ac18407f226d94d5"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"c711f87882d2c7b8edb9b0c1de8a7822c3bf3e47b8890e4ed2f3acabda9cb216"} name: "PR Actions Fixer" "on": @@ -937,16 +937,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-pr-review-addresser.lock.yml b/.github/workflows/gh-aw-pr-review-addresser.lock.yml index 582b58dd..6faee67a 100644 --- a/.github/workflows/gh-aw-pr-review-addresser.lock.yml +++ b/.github/workflows/gh-aw-pr-review-addresser.lock.yml @@ -30,6 +30,7 @@ # - gh-aw-fragments/mcp-pagination.md # - gh-aw-fragments/messages-footer.md # - gh-aw-fragments/network-ecosystems.md +# - gh-aw-fragments/pr-context.md # - gh-aw-fragments/rigor.md # - gh-aw-fragments/runtime-setup.md # - gh-aw-fragments/safe-output-add-comment-pr.md @@ -39,7 +40,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"2ebbb62c9c192157de36a031b5f00326c4271d9663da95969d90631a39d74485"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"9564307564f6118e5125f6b8ddff78b350665801035a5e38eb7ddb58c7372256"} name: "PR Review Addresser" "on": @@ -273,6 +274,11 @@ jobs: - **Use filters**: Combine `perPage` with state, label, or date filters to reduce result size - **Process as you go**: Don't accumulate all pages before acting — process each batch immediately + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + ## PR Context + + PR data is pre-fetched to `/tmp/pr-context/`. Read `/tmp/pr-context/README.md` for a manifest of all available files. Use these as your primary source for PR metadata, diffs, reviews, comments, and linked issues; fall back to API tools only when required data is unavailable. GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' ## Message Footer @@ -318,7 +324,7 @@ jobs: cat << 'GH_AW_PROMPT_EOF' ## reply-to-pull-request-review-comment Limitations - - **Required field**: `comment_id` — the ID of the review comment to reply to. This is the numeric REST comment ID from `get_review_comments`. + - **Required field**: `comment_id` — the numeric REST comment ID (e.g., `2481734562`). From `get_review_comments` this is the `id` field. From `/tmp/pr-context/review_comments.json` (GraphQL) this is the `databaseId` field. Do not pass GraphQL node IDs (e.g., `IC_kwDONVGiRc6...`) — those will fail. - **Body**: Max 65,536 characters. Keep well under this limit. - **Purpose**: Reply directly to a specific review comment thread to explain your reasoning when you disagree with or skip feedback. Do NOT use `add_comment` for this — use this tool to keep replies in context. - **Max per run**: 10 replies per workflow run. @@ -350,11 +356,13 @@ jobs: ### Step 1: Gather Context + PR context is pre-fetched to `/tmp/pr-context/`. Read `/tmp/pr-context/README.md` for a manifest of all available files. + 1. Call `generate_agents_md` to get the repository's coding guidelines and conventions. If this fails, continue without it. - 2. Call `pull_request_read` with method `get` on PR #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ to get the full PR details (author, description, branches). Check whether this is a fork PR — if the head repo differs from the base repo, you cannot push changes. - 3. If the PR description references issues (e.g., "Fixes #123"), call `issue_read` with method `get` on each linked issue to understand the motivation and requirements. - 4. Call `pull_request_read` with method `get_review_comments` to get all review threads. Identify which threads are unresolved and need attention. - 5. Call `pull_request_read` with method `get_diff` to understand the current state of changes. + 2. Read `/tmp/pr-context/pr.json` for PR details (author, description, branches). Check whether this is a fork PR — if the head repo differs from the base repo, you cannot push changes. + 3. Read `/tmp/pr-context/issue-*.json` if any exist to understand linked issue motivation and requirements. + 4. Read `/tmp/pr-context/review_comments.json` to get all review threads. Identify which threads are unresolved and need attention. + 5. Read `/tmp/pr-context/diffs/` to understand the current state of changes. ### Step 2: Address Each Review Thread @@ -362,8 +370,8 @@ jobs: 1. **Read and understand** the reviewer's feedback carefully. 2. **Decide**: Is the feedback actionable? Use your judgment — don't blindly accept every suggestion. - - **If actionable**: Make the code change. Be surgical — change only what's needed to address the specific feedback. - - **If you disagree or it's unclear**: Call `reply_to_pull_request_review_comment` on the specific review comment to explain your reasoning inline. Do NOT resolve the thread — let the reviewer decide. + - **If actionable**: Make the code change. Be surgical — change only what's needed to address the specific feedback. If the fix isn't obvious from the code change alone, call `reply_to_pull_request_review_comment` with the comment's numeric ID to briefly explain what you changed. + - **If you disagree or it's unclear**: Call `reply_to_pull_request_review_comment` with the comment's numeric ID to explain your reasoning inline. Do NOT resolve the thread — let the reviewer decide. 3. **Track** which threads you addressed with code changes vs. which you replied to. ### Step 3: Validate and Push @@ -374,7 +382,9 @@ jobs: ### Step 4: Resolve Addressed Threads - After pushing, resolve each review thread you addressed with code changes by calling `resolve_pull_request_review_thread` with the thread's node ID (the `id` field from `get_review_comments`, e.g., `PRRT_kwDO...`). Only resolve threads you have actually addressed — do not resolve threads you skipped or disagreed with. + Skip this step for fork PRs where you could not push. + + After pushing, resolve every review thread that your changes address by calling `resolve_pull_request_review_thread` with the thread's GraphQL node ID (the `id` field, e.g., `PRRT_kwDO...`). This includes threads from any reviewer — external reviewers, bots, and your own prior reviews. Check `/tmp/pr-context/review_comments.json` for all unresolved threads (`isResolved: false`) — `isOutdated` threads have had the underlying code changed since the comment was made, so check whether your changes address them. Do NOT resolve threads you disagreed with, skipped, or only partially addressed — leave those open for the reviewer. Fall back to `pull_request_read` with method `get_review_comments` if the pre-fetched data is unavailable. ### Step 5: Respond @@ -552,6 +562,11 @@ jobs: chmod +x "$install_dir/uv" echo "$install_dir" >> "$GITHUB_PATH" shell: bash + - env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.target-pr-number || github.event.issue.number }} + name: Fetch PR context to disk + run: "set -euo pipefail\nmkdir -p /tmp/pr-context\n\n# PR metadata\ngh pr view \"$PR_NUMBER\" --json title,body,author,baseRefName,headRefName,url \\\n > /tmp/pr-context/pr.json\n\n# Full diff\nif ! gh pr diff \"$PR_NUMBER\" > /tmp/pr-context/pr.diff; then\n echo \"::warning::Failed to fetch full PR diff; per-file diffs from files.json are still available.\"\n : > /tmp/pr-context/pr.diff\nfi\n\n# Changed files list (--paginate may output concatenated arrays; jq -s 'add' merges them)\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/files.json\n\n# Per-file diffs\njq -c '.[]' /tmp/pr-context/files.json | while IFS= read -r entry; do\n filename=$(echo \"$entry\" | jq -r '.filename')\n mkdir -p \"/tmp/pr-context/diffs/$(dirname \"$filename\")\"\n echo \"$entry\" | jq -r '.patch // empty' > \"/tmp/pr-context/diffs/${filename}.diff\"\ndone\n\n# Existing reviews\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/reviews.json\n\n# Review threads with resolution status (GraphQL — REST lacks isResolved/isOutdated)\ngh api graphql --paginate -f query='\n query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n reviewThreads(first: 100, after: $endCursor) {\n pageInfo { hasNextPage endCursor }\n nodes {\n id\n isResolved\n isOutdated\n isCollapsed\n path\n line\n startLine\n comments(first: 100) {\n nodes {\n id\n databaseId\n body\n author { login }\n createdAt\n }\n }\n }\n }\n }\n }\n }\n' -F owner=\"${GITHUB_REPOSITORY%/*}\" -F repo=\"${GITHUB_REPOSITORY#*/}\" -F \"number=$PR_NUMBER\" \\\n --jq '.data.repository.pullRequest.reviewThreads.nodes' \\\n | jq -s 'add // []' > /tmp/pr-context/review_comments.json\n\n# Per-file review threads (mirrors diffs/ structure)\njq -c '.[]' /tmp/pr-context/review_comments.json | while IFS= read -r thread; do\n filepath=$(echo \"$thread\" | jq -r '.path // empty')\n [ -z \"$filepath\" ] && continue\n mkdir -p \"/tmp/pr-context/threads/$(dirname \"$filepath\")\"\n echo \"$thread\" >> \"/tmp/pr-context/threads/${filepath}.jsonl\"\ndone\n# Convert per-file JSONL to proper JSON arrays\nmkdir -p /tmp/pr-context/threads\nfind /tmp/pr-context/threads -name '*.jsonl' 2>/dev/null | while IFS= read -r jsonl; do\n jq -s '.' \"$jsonl\" > \"${jsonl%.jsonl}.json\"\n rm \"$jsonl\"\ndone\n\n# PR discussion comments\ngh api \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/comments.json\n\n# Linked issues\njq -r '.body // \"\"' /tmp/pr-context/pr.json 2>/dev/null \\\n | grep -oiE '(fixes|closes|resolves)\\s+#[0-9]+' \\\n | grep -oE '[0-9]+$' \\\n | sort -u \\\n | while read -r issue; do\n gh api \"repos/$GITHUB_REPOSITORY/issues/$issue\" > \"/tmp/pr-context/issue-${issue}.json\" || true\n done || true\n\n# Write manifest\ncat > /tmp/pr-context/README.md << 'MANIFEST'\n# PR Context\n\nPre-fetched PR data. All files are in `/tmp/pr-context/`.\n\n| File | Description |\n| --- | --- |\n| `pr.json` | PR metadata — title, body, author, base/head branches, URL |\n| `pr.diff` | Full unified diff of all changes |\n| `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` |\n| `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` |\n| `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body |\n| `review_comments.json` | All review threads (GraphQL) — each thread has `id` (node ID for resolving), `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with `id`, `databaseId` (numeric REST ID for replies), body/author |\n| `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` |\n| `comments.json` | PR discussion comments (not inline) |\n| `issue-{N}.json` | Linked issue details (one file per linked issue, if any) |\n| `agents.md` | Repository conventions from `generate_agents_md` (if written by agent) |\n| `review-instructions.md` | Review instructions, criteria, and calibration examples (if written by review-process fragment) |\nMANIFEST\n\necho \"PR context written to /tmp/pr-context/\"\nls -la /tmp/pr-context/" - env: GITHUB_TOKEN: ${{ github.token }} REPO_NAME: ${{ github.repository }} @@ -1031,16 +1046,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-pr-review-addresser.md b/.github/workflows/gh-aw-pr-review-addresser.md index 9b2e1258..8661ec35 100644 --- a/.github/workflows/gh-aw-pr-review-addresser.md +++ b/.github/workflows/gh-aw-pr-review-addresser.md @@ -8,6 +8,7 @@ imports: - gh-aw-fragments/formatting.md - gh-aw-fragments/rigor.md - gh-aw-fragments/mcp-pagination.md + - gh-aw-fragments/pr-context.md - gh-aw-fragments/messages-footer.md - gh-aw-fragments/safe-output-add-comment-pr.md - gh-aw-fragments/safe-output-push-to-pr.md @@ -118,11 +119,13 @@ Address the review feedback surgically — make only the minimum changes needed. ### Step 1: Gather Context +PR context is pre-fetched to `/tmp/pr-context/`. Read `/tmp/pr-context/README.md` for a manifest of all available files. + 1. Call `generate_agents_md` to get the repository's coding guidelines and conventions. If this fails, continue without it. -2. Call `pull_request_read` with method `get` on PR #${{ github.event.pull_request.number }} to get the full PR details (author, description, branches). Check whether this is a fork PR — if the head repo differs from the base repo, you cannot push changes. -3. If the PR description references issues (e.g., "Fixes #123"), call `issue_read` with method `get` on each linked issue to understand the motivation and requirements. -4. Call `pull_request_read` with method `get_review_comments` to get all review threads. Identify which threads are unresolved and need attention. -5. Call `pull_request_read` with method `get_diff` to understand the current state of changes. +2. Read `/tmp/pr-context/pr.json` for PR details (author, description, branches). Check whether this is a fork PR — if the head repo differs from the base repo, you cannot push changes. +3. Read `/tmp/pr-context/issue-*.json` if any exist to understand linked issue motivation and requirements. +4. Read `/tmp/pr-context/review_comments.json` to get all review threads. Identify which threads are unresolved and need attention. +5. Read `/tmp/pr-context/diffs/` to understand the current state of changes. ### Step 2: Address Each Review Thread @@ -130,8 +133,8 @@ For each unresolved review thread: 1. **Read and understand** the reviewer's feedback carefully. 2. **Decide**: Is the feedback actionable? Use your judgment — don't blindly accept every suggestion. - - **If actionable**: Make the code change. Be surgical — change only what's needed to address the specific feedback. - - **If you disagree or it's unclear**: Call `reply_to_pull_request_review_comment` on the specific review comment to explain your reasoning inline. Do NOT resolve the thread — let the reviewer decide. + - **If actionable**: Make the code change. Be surgical — change only what's needed to address the specific feedback. If the fix isn't obvious from the code change alone, call `reply_to_pull_request_review_comment` with the comment's numeric ID to briefly explain what you changed. + - **If you disagree or it's unclear**: Call `reply_to_pull_request_review_comment` with the comment's numeric ID to explain your reasoning inline. Do NOT resolve the thread — let the reviewer decide. 3. **Track** which threads you addressed with code changes vs. which you replied to. ### Step 3: Validate and Push @@ -142,7 +145,9 @@ For each unresolved review thread: ### Step 4: Resolve Addressed Threads -After pushing, resolve each review thread you addressed with code changes by calling `resolve_pull_request_review_thread` with the thread's node ID (the `id` field from `get_review_comments`, e.g., `PRRT_kwDO...`). Only resolve threads you have actually addressed — do not resolve threads you skipped or disagreed with. +Skip this step for fork PRs where you could not push. + +After pushing, resolve every review thread that your changes address by calling `resolve_pull_request_review_thread` with the thread's GraphQL node ID (the `id` field, e.g., `PRRT_kwDO...`). This includes threads from any reviewer — external reviewers, bots, and your own prior reviews. Check `/tmp/pr-context/review_comments.json` for all unresolved threads (`isResolved: false`) — `isOutdated` threads have had the underlying code changed since the comment was made, so check whether your changes address them. Do NOT resolve threads you disagreed with, skipped, or only partially addressed — leave those open for the reviewer. Fall back to `pull_request_read` with method `get_review_comments` if the pre-fetched data is unavailable. ### Step 5: Respond diff --git a/.github/workflows/gh-aw-pr-review.lock.yml b/.github/workflows/gh-aw-pr-review.lock.yml index 54647e15..3e493aac 100644 --- a/.github/workflows/gh-aw-pr-review.lock.yml +++ b/.github/workflows/gh-aw-pr-review.lock.yml @@ -40,7 +40,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"2a1aba9df1886fcd3c1d269eafbfda269603f64460bd155cbf014010883963e7"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"d941ffb5b429f1f46680b2464a5997d02b5a2c64fa030af30bd120e30ea5deaa"} name: "PR Review" "on": @@ -682,7 +682,7 @@ jobs: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number || inputs.target-pr-number || github.event.issue.number }} name: Fetch PR context to disk - run: "set -euo pipefail\nmkdir -p /tmp/pr-context\n\n# PR metadata\ngh pr view \"$PR_NUMBER\" --json title,body,author,baseRefName,headRefName,url \\\n > /tmp/pr-context/pr.json\n\n# Full diff\nif ! gh pr diff \"$PR_NUMBER\" > /tmp/pr-context/pr.diff; then\n echo \"::warning::Failed to fetch full PR diff; per-file diffs from files.json are still available.\"\n : > /tmp/pr-context/pr.diff\nfi\n\n# Changed files list (--paginate may output concatenated arrays; jq -s 'add' merges them)\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/files.json\n\n# Per-file diffs\njq -c '.[]' /tmp/pr-context/files.json | while IFS= read -r entry; do\n filename=$(echo \"$entry\" | jq -r '.filename')\n mkdir -p \"/tmp/pr-context/diffs/$(dirname \"$filename\")\"\n echo \"$entry\" | jq -r '.patch // empty' > \"/tmp/pr-context/diffs/${filename}.diff\"\ndone\n\n# Existing reviews\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/reviews.json\n\n# Review threads with resolution status (GraphQL — REST lacks isResolved/isOutdated)\ngh api graphql --paginate -f query='\n query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n reviewThreads(first: 100, after: $endCursor) {\n pageInfo { hasNextPage endCursor }\n nodes {\n id\n isResolved\n isOutdated\n isCollapsed\n path\n line\n startLine\n comments(first: 100) {\n nodes {\n id\n body\n author { login }\n createdAt\n }\n }\n }\n }\n }\n }\n }\n' -F owner=\"${GITHUB_REPOSITORY%/*}\" -F repo=\"${GITHUB_REPOSITORY#*/}\" -F \"number=$PR_NUMBER\" \\\n --jq '.data.repository.pullRequest.reviewThreads.nodes' \\\n | jq -s 'add // []' > /tmp/pr-context/review_comments.json\n\n# Per-file review threads (mirrors diffs/ structure)\njq -c '.[]' /tmp/pr-context/review_comments.json | while IFS= read -r thread; do\n filepath=$(echo \"$thread\" | jq -r '.path // empty')\n [ -z \"$filepath\" ] && continue\n mkdir -p \"/tmp/pr-context/threads/$(dirname \"$filepath\")\"\n echo \"$thread\" >> \"/tmp/pr-context/threads/${filepath}.jsonl\"\ndone\n# Convert per-file JSONL to proper JSON arrays\nmkdir -p /tmp/pr-context/threads\nfind /tmp/pr-context/threads -name '*.jsonl' 2>/dev/null | while IFS= read -r jsonl; do\n jq -s '.' \"$jsonl\" > \"${jsonl%.jsonl}.json\"\n rm \"$jsonl\"\ndone\n\n# PR discussion comments\ngh api \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/comments.json\n\n# Linked issues\njq -r '.body // \"\"' /tmp/pr-context/pr.json 2>/dev/null \\\n | grep -oiE '(fixes|closes|resolves)\\s+#[0-9]+' \\\n | grep -oE '[0-9]+$' \\\n | sort -u \\\n | while read -r issue; do\n gh api \"repos/$GITHUB_REPOSITORY/issues/$issue\" > \"/tmp/pr-context/issue-${issue}.json\" || true\n done || true\n\n# Write manifest\ncat > /tmp/pr-context/README.md << 'MANIFEST'\n# PR Context\n\nPre-fetched PR data. All files are in `/tmp/pr-context/`.\n\n| File | Description |\n| --- | --- |\n| `pr.json` | PR metadata — title, body, author, base/head branches, URL |\n| `pr.diff` | Full unified diff of all changes |\n| `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` |\n| `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` |\n| `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body |\n| `review_comments.json` | All review threads (GraphQL) — each thread has `id`, `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with body/author |\n| `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` |\n| `comments.json` | PR discussion comments (not inline) |\n| `issue-{N}.json` | Linked issue details (one file per linked issue, if any) |\n| `agents.md` | Repository conventions from `generate_agents_md` (if written by agent) |\n| `review-instructions.md` | Review instructions, criteria, and calibration examples (if written by review-process fragment) |\nMANIFEST\n\necho \"PR context written to /tmp/pr-context/\"\nls -la /tmp/pr-context/" + run: "set -euo pipefail\nmkdir -p /tmp/pr-context\n\n# PR metadata\ngh pr view \"$PR_NUMBER\" --json title,body,author,baseRefName,headRefName,url \\\n > /tmp/pr-context/pr.json\n\n# Full diff\nif ! gh pr diff \"$PR_NUMBER\" > /tmp/pr-context/pr.diff; then\n echo \"::warning::Failed to fetch full PR diff; per-file diffs from files.json are still available.\"\n : > /tmp/pr-context/pr.diff\nfi\n\n# Changed files list (--paginate may output concatenated arrays; jq -s 'add' merges them)\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/files.json\n\n# Per-file diffs\njq -c '.[]' /tmp/pr-context/files.json | while IFS= read -r entry; do\n filename=$(echo \"$entry\" | jq -r '.filename')\n mkdir -p \"/tmp/pr-context/diffs/$(dirname \"$filename\")\"\n echo \"$entry\" | jq -r '.patch // empty' > \"/tmp/pr-context/diffs/${filename}.diff\"\ndone\n\n# Existing reviews\ngh api \"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/reviews.json\n\n# Review threads with resolution status (GraphQL — REST lacks isResolved/isOutdated)\ngh api graphql --paginate -f query='\n query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n reviewThreads(first: 100, after: $endCursor) {\n pageInfo { hasNextPage endCursor }\n nodes {\n id\n isResolved\n isOutdated\n isCollapsed\n path\n line\n startLine\n comments(first: 100) {\n nodes {\n id\n databaseId\n body\n author { login }\n createdAt\n }\n }\n }\n }\n }\n }\n }\n' -F owner=\"${GITHUB_REPOSITORY%/*}\" -F repo=\"${GITHUB_REPOSITORY#*/}\" -F \"number=$PR_NUMBER\" \\\n --jq '.data.repository.pullRequest.reviewThreads.nodes' \\\n | jq -s 'add // []' > /tmp/pr-context/review_comments.json\n\n# Per-file review threads (mirrors diffs/ structure)\njq -c '.[]' /tmp/pr-context/review_comments.json | while IFS= read -r thread; do\n filepath=$(echo \"$thread\" | jq -r '.path // empty')\n [ -z \"$filepath\" ] && continue\n mkdir -p \"/tmp/pr-context/threads/$(dirname \"$filepath\")\"\n echo \"$thread\" >> \"/tmp/pr-context/threads/${filepath}.jsonl\"\ndone\n# Convert per-file JSONL to proper JSON arrays\nmkdir -p /tmp/pr-context/threads\nfind /tmp/pr-context/threads -name '*.jsonl' 2>/dev/null | while IFS= read -r jsonl; do\n jq -s '.' \"$jsonl\" > \"${jsonl%.jsonl}.json\"\n rm \"$jsonl\"\ndone\n\n# PR discussion comments\ngh api \"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments\" --paginate \\\n | jq -s 'add // []' > /tmp/pr-context/comments.json\n\n# Linked issues\njq -r '.body // \"\"' /tmp/pr-context/pr.json 2>/dev/null \\\n | grep -oiE '(fixes|closes|resolves)\\s+#[0-9]+' \\\n | grep -oE '[0-9]+$' \\\n | sort -u \\\n | while read -r issue; do\n gh api \"repos/$GITHUB_REPOSITORY/issues/$issue\" > \"/tmp/pr-context/issue-${issue}.json\" || true\n done || true\n\n# Write manifest\ncat > /tmp/pr-context/README.md << 'MANIFEST'\n# PR Context\n\nPre-fetched PR data. All files are in `/tmp/pr-context/`.\n\n| File | Description |\n| --- | --- |\n| `pr.json` | PR metadata — title, body, author, base/head branches, URL |\n| `pr.diff` | Full unified diff of all changes |\n| `files.json` | Changed files array — each entry has `filename`, `status`, `additions`, `deletions`, `patch` |\n| `diffs/.diff` | Per-file diffs — one file per changed file, mirroring the repo path under `diffs/` |\n| `reviews.json` | Prior review submissions — author, state (APPROVED/CHANGES_REQUESTED/COMMENTED), body |\n| `review_comments.json` | All review threads (GraphQL) — each thread has `id` (node ID for resolving), `isResolved`, `isOutdated`, `path`, `line`, and nested `comments` with `id`, `databaseId` (numeric REST ID for replies), body/author |\n| `threads/.json` | Per-file review threads — one file per changed file with existing threads, mirroring the repo path under `threads/` |\n| `comments.json` | PR discussion comments (not inline) |\n| `issue-{N}.json` | Linked issue details (one file per linked issue, if any) |\n| `agents.md` | Repository conventions from `generate_agents_md` (if written by agent) |\n| `review-instructions.md` | Review instructions, criteria, and calibration examples (if written by review-process fragment) |\nMANIFEST\n\necho \"PR context written to /tmp/pr-context/\"\nls -la /tmp/pr-context/" - name: Write review instructions to disk run: "mkdir -p /tmp/pr-context\ncat > /tmp/pr-context/review-instructions.md << 'REVIEW_EOF'\n# Review Instructions for Sub-agents\n\nYou are a code review sub-agent. Read these instructions, then review the PR files in the order provided in your prompt.\n\n## Context\n\nBefore reviewing files, read these to understand the PR:\n\n1. `/tmp/pr-context/pr.json` — PR title, description, author, and branches. Understand what the PR is trying to accomplish.\n2. `/tmp/pr-context/agents.md` — Repository coding conventions and guidelines (if it exists).\n3. `/tmp/pr-context/review_comments.json` — Existing review threads. Note which files already have threads so you don't duplicate.\n4. `/tmp/pr-context/issue-*.json` — Linked issue details (if any). Understand the motivation and acceptance criteria.\n\n## Process\n\nReview the PR diff file by file in your assigned order. For each changed file:\n\n1. **Read the diff** for this file from `/tmp/pr-context/diffs/.diff` to understand what changed. If the diff is empty or truncated (e.g., binary files or very large changes), fall back to reading the full file from the workspace and comparing against context.\n2. **Read the full file from the workspace.** The PR branch is checked out locally — open the file directly to get complete contents with line numbers.\n3. **Check existing threads** for this file from `/tmp/pr-context/threads/.json` (if it exists). Skip issues that are already under discussion — each thread has `isResolved` and `isOutdated` fields.\n4. **Identify potential issues** matching the review criteria below.\n5. **Quick-check each issue** before including it:\n - What specific code pattern or change triggers this concern?\n - Is there an obvious guard, handler, or mitigation visible in the immediate context?\n - Can you describe a concrete failure scenario (the `evidence` field)? If you cannot articulate what specific input or state triggers the problem, drop the finding.\n - If the issue is clearly handled, skip it. If you're unsure, include it — the parent will verify.\n6. **Add to your findings list.** Do NOT leave inline comments — you don't have that tool. Return findings in this format:\n\n```\n- file: path/to/file\n line: 42\n severity: HIGH\n title: Brief title\n description: What the issue is and why it matters\n evidence: The specific code pattern and failure scenario\n suggestion: corrected code here (optional — only if you can provide a concrete fix)\n```\n\n**Review every file in your assigned order.** Files reviewed earlier get more attention, which is why different sub-agents use different orderings.\n\n**Check existing threads** — per-file threads are at `/tmp/pr-context/threads/.json` (step 3 above). The full list is at `/tmp/pr-context/review_comments.json`. Do not flag issues that are already under discussion (resolved or unresolved). For outdated threads, only re-flag if the issue still applies to the current diff.\n\n**Return your full findings list** when done, or an empty list if no issues were found.\n\n## Review Criteria\n\nFocus on these categories in priority order:\n\n1. Security vulnerabilities (injection, XSS, auth bypass, secrets exposure)\n2. Logic bugs that could cause runtime failures or incorrect behavior\n3. Data integrity issues (race conditions, missing transactions, corruption risk)\n4. Performance bottlenecks (N+1 queries, memory leaks, blocking operations)\n5. Error handling gaps (unhandled exceptions, missing validation)\n6. Breaking changes to public APIs without migration path\n7. Missing or incorrect test coverage for critical paths\n\n## What NOT to Flag\n\nOnly review the diff — do not flag issues in unchanged code, pre-existing problems not introduced by this PR, or style preferences handled by linters or formatters.\n\n**Common false positives** — these patterns look like issues but usually aren't. Before flagging anything in these categories, confirm the problem is real by reading the surrounding code:\n\n- **Security — input already sanitized:** Don't flag injection or XSS risks when inputs are sanitized upstream, parameterized queries are used, or the framework auto-escapes output.\n- **Null/undefined — guarded elsewhere:** Don't flag potential null dereferences if the value is guaranteed by a type guard, assertion, schema validation, or upstream null check.\n- **Error handling — handled at a different layer:** Don't flag missing try/catch if the caller, middleware, or framework catches and handles the error (e.g., Express error middleware, React error boundaries).\n- **Performance — theoretical, not practical:** Don't flag algorithmic complexity (e.g., O(n^2)) unless N is demonstrably large enough to matter in the actual usage context. \"This could be slow\" without evidence is not actionable.\n- **Validation — exists at another layer:** Don't flag missing input validation if it's handled by an API gateway, middleware, schema validator, or type system.\n- **Test coverage — trivial or generated code:** Don't flag missing tests for trivial getters/setters, auto-generated code, or simple delegation methods.\n- **Style or naming — not in coding guidelines:** Don't flag naming conventions or code style unless they violate the repository's documented coding guidelines (from `generate_agents_md` or CONTRIBUTING docs).\n\n**Existing review threads** — check BEFORE flagging any issue:\n\n- **Resolved with reviewer reply** (e.g. \"This is intentional\") — reviewer's decision is final. Do NOT re-flag.\n- **Resolved without reply** — author likely fixed it. Do NOT re-raise unless the fix introduced a new problem.\n- **Unresolved** — already flagged. Do NOT duplicate.\n- **Outdated** — only re-flag if the issue still applies to the current diff.\n\nWhen in doubt, do not duplicate. Redundant comments erode trust.\n\nFinding no issues is a valid and valuable outcome. An empty findings list is better than findings that waste the author's time or erode trust. Do not manufacture findings to justify your review — if the code is sound, return an empty list.\n\n## Severity Classification\n\nDetermine severity AFTER investigating the issue, not before. First identify the problem and trace through the code, then assign a severity based on the evidence you found.\n\n- 🔴 CRITICAL — Must fix before merge (security vulnerabilities, data corruption, production-breaking bugs)\n- 🟠 HIGH — Should fix before merge (logic errors, missing validation, significant performance issues)\n- 🟡 MEDIUM — Address soon, non-blocking (error handling gaps, suboptimal patterns, missing edge cases)\n- ⚪ LOW — Author discretion (minor improvements, documentation gaps)\n- 💬 NITPICK — Truly optional (stylistic preferences, alternative approaches)\n\n## Review Intensity\n\nThe review intensity is `${{ inputs.intensity || 'balanced' }}`.\n\n- **conservative**: High evidence bar. Only flag when you can demonstrate a concrete failure scenario. If you can construct a reasonable counterargument, do not flag. Approval with zero findings is the expected outcome for most PRs.\n- **balanced**: Standard evidence bar. Flag when you can point to specific code that would fail. If the issue is ambiguous, lean toward not flagging.\n- **aggressive**: Lower evidence bar. Flag when evidence exists even if the failure scenario is not fully confirmed. Improvement suggestions welcome but must cite specific code.\n\n## Calibration Examples\n\nUse these examples to calibrate your judgment. Each pair shows a real issue and a similar-looking pattern that is NOT an issue.\n\n### Example 1: Null/Undefined Access\n\n**True positive — flag this:**\n\n```js\n// PR adds this handler\napp.get('/user/:id', async (req, res) => {\n const user = await db.findUser(req.params.id);\n res.json({ name: user.name, email: user.email });\n});\n```\n\nWhy flag: `db.findUser()` can return `null` when no user matches the ID. Accessing `user.name` will throw a TypeError at runtime. No upstream guard exists — the route handler is the entry point.\n\n**False positive — do NOT flag this:**\n\n```ts\n// PR adds this line inside an existing function\nconst settings = user.getSettings();\n```\n\nWhy skip: Reading the full file reveals `user` is typed as `User` (not `User | null`), and the calling function only runs after `authenticateUser()` middleware which guarantees a valid user object. The null case is handled at a different layer.\n\n### Example 2: SQL Injection\n\n**True positive — flag this:**\n\n```python\n# PR adds this query\ncursor.execute(f\"SELECT * FROM orders WHERE customer_id = '{customer_id}'\")\n```\n\nWhy flag: String interpolation in a SQL query with user-controlled input (`customer_id` comes from the request). No parameterization or sanitization anywhere in the call chain.\n\n**False positive — do NOT flag this:**\n\n```python\n# PR adds this query\ncursor.execute(f\"SELECT * FROM orders WHERE status = '{OrderStatus.PENDING.value}'\")\n```\n\nWhy skip: The interpolated value is a hardcoded enum constant (`OrderStatus.PENDING`), not user input. There is no injection vector.\n\n### Example 3: Borderline — Do NOT Flag\n\n```go\n// PR adds this function\nfunc processItems(items []Item) []Result {\n results := make([]Result, 0)\n for _, item := range items {\n for _, tag := range item.Tags {\n results = append(results, process(item, tag))\n }\n }\n return results\n}\n```\n\nThis looks like an O(n*m) performance concern. But without evidence that `items` or `Tags` are large in practice, this is speculative. The function processes a bounded dataset (items from a single user request). Do not flag theoretical performance issues without evidence of real-world impact.\nREVIEW_EOF" - env: diff --git a/.github/workflows/gh-aw-release-update.lock.yml b/.github/workflows/gh-aw-release-update.lock.yml index f0e0a132..aed78da4 100644 --- a/.github/workflows/gh-aw-release-update.lock.yml +++ b/.github/workflows/gh-aw-release-update.lock.yml @@ -36,7 +36,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"66662cc958a418f86b88e9f30b0aaa25e00c409f0bb83359a79c1a6874a36c97"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"0b6237e0806733f975c6d12c6f256274868588e757ee89bcafc8a9bc8fdd5efd"} name: "Release Update Check" "on": @@ -892,16 +892,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-scheduled-fix.lock.yml b/.github/workflows/gh-aw-scheduled-fix.lock.yml index 23ea695e..95b2fa95 100644 --- a/.github/workflows/gh-aw-scheduled-fix.lock.yml +++ b/.github/workflows/gh-aw-scheduled-fix.lock.yml @@ -37,7 +37,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"390e57830044e0c5b3d4a68da4a88a522e707e2b78640366d8c331b1c05dd621"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"24f475e3a2993dd129ad8be4404ab5fd3e4e081deabbef5b4a10db20862dbe38"} name: "Scheduled Fix" "on": @@ -947,16 +947,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-small-problem-fixer.lock.yml b/.github/workflows/gh-aw-small-problem-fixer.lock.yml index 0cc24f86..09ed0983 100644 --- a/.github/workflows/gh-aw-small-problem-fixer.lock.yml +++ b/.github/workflows/gh-aw-small-problem-fixer.lock.yml @@ -37,7 +37,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"e7fcb156e6fc7ca8913fb677d677f5554dd62356160a3f4997e2b57750f4e598"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"e0c2c421f13e9ce468bdb7536716845eb1bfe5f3dd96327f2c35c9e06ffc6bec"} name: "Small Problem Fixer" "on": @@ -975,16 +975,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-test-improvement.lock.yml b/.github/workflows/gh-aw-test-improvement.lock.yml index ef87580e..e2284d23 100644 --- a/.github/workflows/gh-aw-test-improvement.lock.yml +++ b/.github/workflows/gh-aw-test-improvement.lock.yml @@ -41,7 +41,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"748ba1ff272dc8ecf46ed625c6db613cb8ccfea1264085eb66319ce2505a5023"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"8aaa1fb234b83c6087c0ca590188b4b6240204eb8e085d3969d3f017a84f8898"} name: "Test Improver" "on": @@ -935,16 +935,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-test-improver.lock.yml b/.github/workflows/gh-aw-test-improver.lock.yml index 68314165..10a5df80 100644 --- a/.github/workflows/gh-aw-test-improver.lock.yml +++ b/.github/workflows/gh-aw-test-improver.lock.yml @@ -36,7 +36,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"748ba1ff272dc8ecf46ed625c6db613cb8ccfea1264085eb66319ce2505a5023"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"8aaa1fb234b83c6087c0ca590188b4b6240204eb8e085d3969d3f017a84f8898"} name: "Test Improver" "on": @@ -930,16 +930,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/.github/workflows/gh-aw-text-beautifier.lock.yml b/.github/workflows/gh-aw-text-beautifier.lock.yml index 528d0d69..a89cedb0 100644 --- a/.github/workflows/gh-aw-text-beautifier.lock.yml +++ b/.github/workflows/gh-aw-text-beautifier.lock.yml @@ -38,7 +38,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"05acaf87ad876a6e7cfe219331cbefb85feb22851837b9b4b903ad0b61e4e7c9"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"2c95f9b03d6a21556f4408e2a3163ae2e781dfe8c8db8793c44f01d3581ee1dc"} name: "Text Beautifier" "on": @@ -932,16 +932,52 @@ jobs: inputs = {} # User code: - import os, json + import os, json, subprocess def find(*paths): return next((p for p in paths if os.path.isfile(p)), None) + def run(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, stdout='', stderr='diff timed out') contributing = find('CONTRIBUTING.md', 'CONTRIBUTING.rst', 'docs/CONTRIBUTING.md', 'docs/contributing.md') pr_template = find('.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md') + # Generate diff of all local changes vs upstream for self-review + # Try --merge-base (committed+staged+unstaged vs upstream), fall back to + # @{upstream} 2-dot (committed only), then HEAD (uncommitted only) + diff_text = '' + for diff_cmd in [ + ['git', 'diff', '--merge-base', '@{upstream}'], + ['git', 'diff', '@{upstream}'], + ['git', 'diff', 'HEAD'], + ]: + result = run(diff_cmd) + if result.stdout.strip(): + diff_text = result.stdout.strip() + break + stat_text = '' + for stat_cmd in [ + ['git', 'diff', '--stat', '--merge-base', '@{upstream}'], + ['git', 'diff', '--stat', '@{upstream}'], + ['git', 'diff', '--stat', 'HEAD'], + ]: + result = run(stat_cmd) + if result.stdout.strip(): + stat_text = result.stdout.strip() + break + os.makedirs('/tmp/self-review', exist_ok=True) + with open('/tmp/self-review/diff.patch', 'w') as f: + f.write(diff_text) + with open('/tmp/self-review/stat.txt', 'w') as f: + f.write(stat_text) + diff_line_count = len(diff_text.splitlines()) checklist = [] if contributing: checklist.append(f'Review the contributing guide ({contributing}) before opening or updating a PR.') if pr_template: checklist.append(f'Follow the PR template ({pr_template}) for title, description, and validation notes.') checklist.append('Confirm the requested task is fully completed and validated before creating or pushing PR changes.') - print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template})) + if diff_line_count > 0: + checklist.append(f'A diff of your unpushed changes ({diff_line_count} lines) has been saved to `/tmp/self-review/diff.patch` (full diff) and `/tmp/self-review/stat.txt` (summary). Spawn a `code-review` sub-agent via `runSubagent` to review the diff against the codebase. Tell it to read `/tmp/self-review/diff.patch` and the relevant source files, and look for bugs, logic errors, missed edge cases, and style issues. If the sub-agent finds legitimate issues, fix them, commit, and call `ready_to_make_pr` again to regenerate the diff before proceeding.') + print(json.dumps({'status': 'ok', 'checklist': checklist, 'contributing_guide': contributing, 'pr_template': pr_template, 'diff_line_count': diff_line_count})) GH_AW_SAFE_INPUTS_PY_READY-TO-MAKE-PR_EOF diff --git a/Makefile b/Makefile index c799eb82..e99704ec 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ define download-file fi endef -.PHONY: help setup setup-actionlint setup-action-validator setup-gh setup-gh-macos setup-gh-debian setup-gh-aw compile sync lint-workflows lint-actions docs-install docs-serve docs-build release +.PHONY: help setup setup-actionlint setup-action-validator setup-gh setup-gh-macos setup-gh-debian setup-gh-aw compile sync lint-workflows lint-actions test docs-install docs-serve docs-build release help: @echo "This repository contains GitHub Actions workflows and gh-agent-workflows templates." @@ -225,6 +225,9 @@ lint-actions: setup-action-validator lint: lint-workflows lint-actions @python3 scripts/check-nav-catalog.py +test: + @uv run --extra test pytest tests/ -v + docs-install: @uv sync --dev diff --git a/pyproject.toml b/pyproject.toml index 3711da2c..ef1997c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,3 +7,12 @@ dependencies = [ "mkdocs>=1.6.0,<2.0", "mkdocs-material>=9.5.0", ] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", + "pyyaml>=6.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_safe_input_ready_to_make_pr.py b/tests/test_safe_input_ready_to_make_pr.py new file mode 100644 index 00000000..ee275ef5 --- /dev/null +++ b/tests/test_safe_input_ready_to_make_pr.py @@ -0,0 +1,370 @@ +"""Tests for the ready-to-make-pr safe-input Python scripts. + +Extracts the `py:` blocks from safe-output-push-to-pr.md and +safe-output-create-pr.md, runs them in controlled git environments, +and validates the JSON output and side effects. +""" + +import json +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest +import yaml + +FRAGMENTS_DIR = ( + Path(__file__).resolve().parent.parent + / ".github" + / "workflows" + / "gh-aw-fragments" +) + +PUSH_FRAGMENT = FRAGMENTS_DIR / "safe-output-push-to-pr.md" +CREATE_FRAGMENT = FRAGMENTS_DIR / "safe-output-create-pr.md" + + +def extract_py_block(fragment_path: Path) -> str: + """Extract the py: block from a safe-input fragment's YAML frontmatter.""" + text = fragment_path.read_text() + # Strip leading/trailing --- to get YAML frontmatter + parts = text.split("---", 2) + assert len(parts) >= 3, f"Expected YAML frontmatter in {fragment_path}" + frontmatter = yaml.safe_load(parts[1]) + py_code = frontmatter["safe-inputs"]["ready-to-make-pr"]["py"] + assert py_code, f"No py: block found in {fragment_path}" + return py_code + + +def run_py_in_repo(py_code: str, repo_dir: str) -> dict: + """Run extracted Python code in a git repo and return parsed JSON output.""" + result = subprocess.run( + ["python3", "-c", py_code], + capture_output=True, + text=True, + cwd=repo_dir, + timeout=30, + ) + assert result.returncode == 0, ( + f"Script failed (rc={result.returncode}):\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + output = result.stdout.strip() + assert output, "Script produced no output" + return json.loads(output) + + +def make_git_repo(tmp_path: Path, *, with_upstream: bool = False) -> Path: + """Create a minimal git repo. Optionally set up a remote upstream.""" + repo = tmp_path / "repo" + repo.mkdir() + + def git(*args): + subprocess.run( + ["git"] + list(args), + cwd=str(repo), + capture_output=True, + check=True, + ) + + git("init", "-b", "main") + git("config", "user.email", "test@test.com") + git("config", "user.name", "Test") + + # Initial commit + (repo / "file.txt").write_text("hello\n") + git("add", "file.txt") + git("commit", "-m", "init") + + if with_upstream: + # Create a bare remote and push + remote = tmp_path / "remote.git" + subprocess.run( + ["git", "clone", "--bare", str(repo), str(remote)], + capture_output=True, + check=True, + ) + git("remote", "add", "origin", str(remote)) + git("fetch", "origin") + git("branch", "--set-upstream-to", "origin/main", "main") + + return repo + + +# --------------------------------------------------------------------------- +# Extraction tests +# --------------------------------------------------------------------------- + + +class TestExtraction: + """Verify we can extract valid Python from both fragments.""" + + def test_push_fragment_exists(self): + assert PUSH_FRAGMENT.exists() + + def test_create_fragment_exists(self): + assert CREATE_FRAGMENT.exists() + + def test_push_extract(self): + code = extract_py_block(PUSH_FRAGMENT) + assert "import" in code + assert "json.dumps" in code + + def test_create_extract(self): + code = extract_py_block(CREATE_FRAGMENT) + assert "import" in code + assert "json.dumps" in code + + def test_fragments_have_identical_py(self): + """The two fragments should have identical Python logic.""" + push_code = extract_py_block(PUSH_FRAGMENT) + create_code = extract_py_block(CREATE_FRAGMENT) + assert push_code == create_code + + +# --------------------------------------------------------------------------- +# Output schema tests +# --------------------------------------------------------------------------- + + +class TestOutputSchema: + """Validate JSON output structure across scenarios.""" + + @pytest.fixture + def py_code(self): + return extract_py_block(PUSH_FRAGMENT) + + def test_basic_output_fields(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + output = run_py_in_repo(py_code, str(repo)) + + assert output["status"] == "ok" + assert isinstance(output["checklist"], list) + assert isinstance(output["diff_line_count"], int) + assert "contributing_guide" in output + assert "pr_template" in output + + def test_checklist_always_has_validation_item(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + output = run_py_in_repo(py_code, str(repo)) + + checklist_text = " ".join(output["checklist"]) + assert "fully completed and validated" in checklist_text + + +# --------------------------------------------------------------------------- +# Git diff fallback tests +# --------------------------------------------------------------------------- + + +class TestDiffFallbacks: + """Test the 3-step diff fallback chain.""" + + @pytest.fixture + def py_code(self): + return extract_py_block(PUSH_FRAGMENT) + + def test_with_upstream_uncommitted_changes(self, py_code, tmp_path): + """--merge-base @{upstream} should capture uncommitted changes.""" + repo = make_git_repo(tmp_path, with_upstream=True) + (repo / "file.txt").write_text("hello\nworld\n") + + output = run_py_in_repo(py_code, str(repo)) + assert output["diff_line_count"] > 0 + + diff_file = Path("/tmp/self-review/diff.patch") + assert diff_file.exists() + assert "world" in diff_file.read_text() + + def test_with_upstream_committed_changes(self, py_code, tmp_path): + """--merge-base @{upstream} should capture committed unpushed changes.""" + repo = make_git_repo(tmp_path, with_upstream=True) + (repo / "new_file.txt").write_text("new content\n") + subprocess.run( + ["git", "add", "new_file.txt"], + cwd=str(repo), + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "commit", "-m", "add new file"], + cwd=str(repo), + capture_output=True, + check=True, + ) + + output = run_py_in_repo(py_code, str(repo)) + assert output["diff_line_count"] > 0 + assert "new content" in Path("/tmp/self-review/diff.patch").read_text() + + def test_no_upstream_uncommitted_changes(self, py_code, tmp_path): + """Falls back to git diff HEAD when no upstream is configured.""" + repo = make_git_repo(tmp_path, with_upstream=False) + (repo / "file.txt").write_text("hello\nchanged\n") + + output = run_py_in_repo(py_code, str(repo)) + assert output["diff_line_count"] > 0 + assert "changed" in Path("/tmp/self-review/diff.patch").read_text() + + def test_no_upstream_committed_changes(self, py_code, tmp_path): + """Falls back through chain; git diff @{upstream} (2-dot) also fails, + git diff HEAD shows nothing since changes are committed. diff_line_count is 0.""" + repo = make_git_repo(tmp_path, with_upstream=False) + (repo / "extra.txt").write_text("extra\n") + subprocess.run( + ["git", "add", "extra.txt"], + cwd=str(repo), + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "commit", "-m", "add extra"], + cwd=str(repo), + capture_output=True, + check=True, + ) + + output = run_py_in_repo(py_code, str(repo)) + # No upstream and no uncommitted changes — diff is empty + assert output["diff_line_count"] == 0 + + def test_no_changes_at_all(self, py_code, tmp_path): + """No changes yields empty diff and no self-review checklist item.""" + repo = make_git_repo(tmp_path, with_upstream=True) + + output = run_py_in_repo(py_code, str(repo)) + assert output["diff_line_count"] == 0 + + checklist_text = " ".join(output["checklist"]) + assert "self-review" not in checklist_text + + +# --------------------------------------------------------------------------- +# File output tests +# --------------------------------------------------------------------------- + + +class TestFileOutput: + """Verify files are created with expected content.""" + + @pytest.fixture + def py_code(self): + return extract_py_block(PUSH_FRAGMENT) + + def test_diff_patch_created(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + (repo / "file.txt").write_text("modified\n") + + run_py_in_repo(py_code, str(repo)) + + assert Path("/tmp/self-review/diff.patch").exists() + assert Path("/tmp/self-review/stat.txt").exists() + + def test_stat_matches_diff(self, py_code, tmp_path): + """stat.txt should reference the same files as the diff.""" + repo = make_git_repo(tmp_path, with_upstream=True) + (repo / "file.txt").write_text("modified\n") + + run_py_in_repo(py_code, str(repo)) + + stat = Path("/tmp/self-review/stat.txt").read_text() + assert "file.txt" in stat + + def test_empty_diff_writes_empty_files(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + + run_py_in_repo(py_code, str(repo)) + + assert Path("/tmp/self-review/diff.patch").read_text() == "" + assert Path("/tmp/self-review/stat.txt").read_text() == "" + + +# --------------------------------------------------------------------------- +# Contributing / PR template detection +# --------------------------------------------------------------------------- + + +class TestFileDetection: + """Test detection of CONTRIBUTING.md and PR template files.""" + + @pytest.fixture + def py_code(self): + return extract_py_block(PUSH_FRAGMENT) + + def test_finds_contributing(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + (repo / "CONTRIBUTING.md").write_text("# Contributing\n") + + output = run_py_in_repo(py_code, str(repo)) + assert output["contributing_guide"] == "CONTRIBUTING.md" + assert any("contributing guide" in c.lower() for c in output["checklist"]) + + def test_finds_nested_contributing(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + (repo / "docs").mkdir() + (repo / "docs" / "CONTRIBUTING.md").write_text("# Contributing\n") + + output = run_py_in_repo(py_code, str(repo)) + assert output["contributing_guide"] == "docs/CONTRIBUTING.md" + + def test_no_contributing(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + + output = run_py_in_repo(py_code, str(repo)) + assert output["contributing_guide"] is None + assert not any("contributing guide" in c.lower() for c in output["checklist"]) + + def test_finds_pr_template(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + (repo / ".github").mkdir() + (repo / ".github" / "pull_request_template.md").write_text("## PR\n") + + output = run_py_in_repo(py_code, str(repo)) + assert output["pr_template"] == ".github/pull_request_template.md" + + def test_no_pr_template(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + + output = run_py_in_repo(py_code, str(repo)) + assert output["pr_template"] is None + + +# --------------------------------------------------------------------------- +# Self-review checklist gating +# --------------------------------------------------------------------------- + + +class TestSelfReviewGating: + """The self-review checklist item should only appear when there's a diff.""" + + @pytest.fixture + def py_code(self): + return extract_py_block(PUSH_FRAGMENT) + + def test_self_review_present_when_diff(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + (repo / "file.txt").write_text("changed\n") + + output = run_py_in_repo(py_code, str(repo)) + checklist_text = " ".join(output["checklist"]) + assert "self-review" in checklist_text + assert "diff.patch" in checklist_text + + def test_self_review_absent_when_no_diff(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + + output = run_py_in_repo(py_code, str(repo)) + checklist_text = " ".join(output["checklist"]) + assert "self-review" not in checklist_text + + def test_diff_line_count_in_checklist(self, py_code, tmp_path): + repo = make_git_repo(tmp_path, with_upstream=True) + (repo / "file.txt").write_text("line1\nline2\nline3\n") + + output = run_py_in_repo(py_code, str(repo)) + count = output["diff_line_count"] + assert count > 0 + # The line count should appear in the checklist text + assert f"({count} lines)" in " ".join(output["checklist"]) diff --git a/uv.lock b/uv.lock index 08b56bdb..efa03341 100644 --- a/uv.lock +++ b/uv.lock @@ -11,11 +11,20 @@ dependencies = [ { name = "mkdocs-material" }, ] +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pyyaml" }, +] + [package.metadata] requires-dist = [ { name = "mkdocs", specifier = ">=1.6.0,<2.0" }, { name = "mkdocs-material", specifier = ">=9.5.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, + { name = "pyyaml", marker = "extra == 'test'", specifier = ">=6.0" }, ] +provides-extras = ["test"] [[package]] name = "babel" @@ -159,6 +168,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -180,6 +201,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -400,6 +430,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -422,6 +461,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -534,6 +591,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"