diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11fe459..5db668c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Test Repo Assist guards + run: python3 -m unittest discover -s tests -p 'test_repo_assist_*.py' - name: Install gh-aw run: gh extension install github/gh-aw env: diff --git a/docs/repo-assist.md b/docs/repo-assist.md index 1c7d2f2..245c2d5 100644 --- a/docs/repo-assist.md +++ b/docs/repo-assist.md @@ -138,7 +138,7 @@ Repo Assist stores this file on the dedicated `memory/repo-assist-memory` branch - **Release preparation**: Uses judgement each run to assess whether a release is warranted — no dedicated release task; proposes release PRs on its own initiative when appropriate - **Good contributor etiquette**: Warmly welcomes first-time contributors and points them to README and CONTRIBUTING as a normal part of good behaviour -For scheduled runs, the workflow is skipped if there are already 8 or more open PRs with its title prefix, to avoid overwhelming maintainers. +Scheduled runs with 8 or more open PRs with the `[repo-assist]` title prefix enter **maintenance-only mode** rather than skipping the workflow. They perform Tasks 6, 1, and 2: maintain existing PRs, label issues, and investigate without opening new PRs. Memory and monthly reporting still apply after substantive work. Below capacity, new PRs are limited to the available slots (and the usual per-run maximum of four). Before applying scheduled safe outputs, the workflow recounts live PRs and rejects a batch that would exceed eight; maintenance and triage outputs remain allowed above capacity. Manual dispatches and command-mode runs are exempt from this scheduled-run cap. ## Usage diff --git a/tests/test_repo_assist_maintenance.py b/tests/test_repo_assist_maintenance.py new file mode 100644 index 0000000..7a2c3d1 --- /dev/null +++ b/tests/test_repo_assist_maintenance.py @@ -0,0 +1,147 @@ +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest +from unittest.mock import patch + + +WORKFLOW = (Path(__file__).parents[1] / "workflows/repo-assist.md").read_text() + + +def script_after(marker, indent): + return textwrap.dedent(WORKFLOW.split(marker, 1)[1].split(f"\n{' ' * indent}EOF", 1)[0]) + + +SELECTION = script_after(" python3 - << 'EOF'\n", 6) +CAPACITY = script_after(" node <<'EOF'\n", 10) + + +class MaintenanceModeTests(unittest.TestCase): + def select(self, count, event="schedule", other_prs=0): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "issues.json").write_text("[]") + prs = [{"title": f"[repo-assist] Fix {i}"} for i in range(count)] + prs += [{"title": f"Human PR {i}"} for i in range(other_prs)] + (root / "prs.json").write_text(json.dumps(prs)) + original_open = open + + def local_open(path, *args, **kwargs): + if str(path).startswith("/tmp/gh-aw/"): + path = root / Path(path).name + return original_open(path, *args, **kwargs) + + with patch("builtins.open", local_open), patch.dict( + os.environ, GITHUB_EVENT_NAME=event, GITHUB_RUN_ID="123" + ), contextlib.redirect_stdout(io.StringIO()): + exec(compile(SELECTION, "task_selection", "exec"), {}) + return json.loads((root / "task_selection.json").read_text()) + + def test_below_capacity_preserves_three_weighted_tasks(self): + for count, slots in [(0, 4), (4, 4), (7, 1)]: + with self.subTest(count=count): + result = self.select(count) + self.assertFalse(result["maintenance_only"]) + self.assertEqual(result["new_pr_limit"], slots) + self.assertEqual(len(set(result["selected_tasks"])), 3) + self.assertEqual(result, self.select(count)) + + def test_at_and_above_capacity_prioritizes_maintenance_and_triage(self): + for count in [8, 9, 200]: + with self.subTest(count=count): + result = self.select(count) + self.assertTrue(result["maintenance_only"]) + self.assertEqual(result["new_pr_limit"], 0) + self.assertEqual(result["selected_tasks"], [6, 1, 2]) + + def test_human_prs_do_not_consume_capacity(self): + self.assertFalse(self.select(7, other_prs=250)["maintenance_only"]) + + def test_manual_and_command_events_remain_exempt(self): + for event in ["workflow_dispatch", "issue_comment", "issues"]: + with self.subTest(event=event): + result = self.select(20, event) + self.assertFalse(result["maintenance_only"]) + self.assertEqual(result["new_pr_limit"], 4) + + def test_cap_no_longer_gates_activation(self): + self.assertNotIn("needs.pre_activation.outputs.check_result", WORKFLOW) + self.assertEqual(WORKFLOW.count("if: github.event_name == 'schedule'"), 2) + self.assertIn('--paginate "repos/$GITHUB_REPOSITORY/pulls', WORKFLOW) + + def check_capacity(self, count, requests, other_prs=0, api_failure=False): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / "output.json" + prs_file = root / "prs.json" + gh = root / "gh" + gh.write_text( + "#!/usr/bin/env python3\n" + "import os, pathlib, sys\n" + "if os.environ['API_FAILURE'] == 'true':\n" + " sys.exit('GitHub API unavailable')\n" + "print(pathlib.Path(os.environ['PRS_FILE']).read_text())\n" + ) + gh.chmod(0o755) + output.write_text(json.dumps({"items": requests})) + prs_file.write_text("\n".join( + [json.dumps("[repo-assist] Fix")] * count + + [json.dumps("Human PR")] * other_prs + )) + return subprocess.run( + ["node", "-e", CAPACITY], + env=dict( + os.environ, OUTPUT_FILE=str(output), PRS_FILE=str(prs_file), + PATH=f"{root}{os.pathsep}{os.environ['PATH']}", + GITHUB_SERVER_URL="https://github.com", GITHUB_REPOSITORY="example/project", + API_FAILURE=str(api_failure).lower(), + ), + capture_output=True, text=True, check=False, + ) + + def test_gate_allows_new_prs_within_available_slots(self): + for count, requested in [(0, 4), (7, 1)]: + with self.subTest(count=count): + result = self.check_capacity(count, [{"type": "create_pull_request"}] * requested) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_gate_rejects_full_capacity_and_oversized_batches(self): + for count, requested in [(7, 2), (8, 1), (9, 1)]: + with self.subTest(count=count): + result = self.check_capacity(count, [{"type": "create_pull_request"}] * requested) + self.assertNotEqual(result.returncode, 0) + self.assertIn("capacity exceeded", result.stderr) + + def test_gate_allows_maintenance_and_triage_above_capacity(self): + result = self.check_capacity(12, [ + {"type": "push_to_pull_request_branch"}, + {"type": "add_labels"}, + {"type": "update_issue"}, + ]) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_gate_allows_noop_above_capacity(self): + result = self.check_capacity(12, [{"type": "noop"}]) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_gate_does_not_count_human_prs(self): + result = self.check_capacity(7, [{"type": "create_pull_request"}], other_prs=250) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_api_failure_does_not_become_zero_open_prs(self): + result = self.check_capacity(8, [{"type": "create_pull_request"}], api_failure=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("GitHub API unavailable", result.stderr) + + def test_maintenance_does_not_need_a_creation_capacity_api_call(self): + result = self.check_capacity(8, [{"type": "add_labels"}], api_failure=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/workflows/repo-assist.md b/workflows/repo-assist.md index bbbfae8..18cb72e 100644 --- a/workflows/repo-assist.md +++ b/workflows/repo-assist.md @@ -25,24 +25,10 @@ on: slash_command: name: repo-assist reaction: "eyes" - permissions: - pull-requests: read - steps: - - id: check - env: - GH_TOKEN: ${{ github.token }} - run: | - MAX_OPEN_PRS=8 - if [[ "$GITHUB_EVENT_NAME" != "schedule" ]]; then exit 0; fi - COUNT=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --search 'in:title "[repo-assist]"' --json number --jq 'length') - [[ "$COUNT" -lt "$MAX_OPEN_PRS" ]] - # exits 0 if not scheduled or item.type === "create_pull_request").length; + if (requested > 0) { + const response = execFileSync("gh", [ + "api", "--hostname", new URL(process.env.GITHUB_SERVER_URL).host, "--paginate", + `repos/${process.env.GITHUB_REPOSITORY}/pulls?state=open&per_page=100`, + "--jq", ".[] | .title | @json", + ], { encoding: "utf8" }); + const titles = response.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); + const open = titles.filter(title => title.startsWith("[repo-assist]")).length; + if (open + requested > 8) { + throw new Error(`Repo Assist PR capacity exceeded: ${open} open + ${requested} requested; maximum 8. Maintain existing PRs instead.`); + } + console.log(`Repo Assist PR capacity: ${open} open, ${requested} requested; outputs allowed.`); + } else { + console.log("No new PRs requested; maintenance and triage outputs allowed."); + } + EOF + steps: - name: Initialize Repo Assist memory env: @@ -245,13 +268,15 @@ steps: env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail mkdir -p /tmp/gh-aw # Fetch open issues with labels (up to 500) gh issue list --state open --limit 500 --json number,labels > /tmp/gh-aw/issues.json - # Fetch open PRs with titles (up to 200) - gh pr list --state open --limit 200 --json number,title > /tmp/gh-aw/prs.json + # Fetch all open PRs so the capacity decision cannot miss older PRs. + gh api --hostname "${GITHUB_SERVER_URL#https://}" --paginate "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" \ + --jq '.[] | {number, title}' | jq -s . > /tmp/gh-aw/prs.json # Compute task weights and select three tasks for this run python3 - << 'EOF' @@ -265,6 +290,7 @@ steps: open_issues = len(issues) unlabelled = sum(1 for i in issues if not i.get('labels')) repo_assist_prs = sum(1 for p in prs if p['title'].startswith('[repo-assist]')) + maintenance_only = os.environ.get('GITHUB_EVENT_NAME') == 'schedule' and repo_assist_prs >= 8 task_names = { 1: 'Issue Labelling', @@ -309,10 +335,14 @@ steps: if len(chosen) == NUM_TASKS_PER_RUN: break + if maintenance_only: + chosen = [6, 1, 2] + print('=== Repo Assist Task Selection ===') print(f'Open issues : {open_issues}') print(f'Unlabelled issues : {unlabelled}') print(f'Repo Assist PRs : {repo_assist_prs}') + print(f'Mode : {"maintenance-only" if maintenance_only else "normal"}') print() print('Task weights:') for t, w in weights.items(): @@ -324,6 +354,8 @@ steps: result = { 'open_issues': open_issues, 'unlabelled_issues': unlabelled, 'repo_assist_prs': repo_assist_prs, + 'maintenance_only': maintenance_only, + 'new_pr_limit': max(0, min(4, 8 - repo_assist_prs)) if os.environ.get('GITHUB_EVENT_NAME') == 'schedule' else 4, 'task_names': task_names, 'weights': {str(k): round(v, 2) for k, v in weights.items()}, 'selected_tasks': chosen, @@ -348,6 +380,12 @@ Then exit - do not run the normal workflow after completing the instructions. You are Repo Assist for `${{ github.repository }}`. Your job is to make forward progress by investigating and clarifying issues, resolving issues where possible, identifying improvements, and fixing bugs and feature requests by creating pull requests. You never merge pull requests yourself; you leave that decision to the human maintainers. +### Scheduled PR capacity + +Read `maintenance_only` and `new_pr_limit` in `/tmp/gh-aw/task_selection.json`. Scheduled runs with eight or more open `[repo-assist]` PRs use **maintenance-only mode**: execute Tasks 6, 1, and 2, maintaining existing PRs, labelling, and investigating issues without creating any new PRs. Task 2 and all fallbacks remain investigation/triage-only in this mode; record fix candidates in memory instead of opening PRs. Continue normal memory updates and Task 11 reporting after substantive work. + +Below capacity, do not create more than `new_pr_limit` PRs. A final safe-output check recounts live PRs and rejects a batch that would exceed eight; existing-PR updates and triage remain allowed even above capacity. Manual dispatches and command-mode runs retain their existing behaviour and are not restricted by this scheduled-run cap. + Always be: - **Polite and encouraging**: Every contributor deserves respect. Use warm, inclusive language.