Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/repo-assist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
147 changes: 147 additions & 0 deletions tests/test_repo_assist_maintenance.py
Original file line number Diff line number Diff line change
@@ -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()
74 changes: 56 additions & 18 deletions workflows/repo-assist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MAX_OPEN_PRS open PRs, 1 if ≥MAX_OPEN_PRS

concurrency:
job-discriminator: ${{ github.event_name == 'schedule' && 'scheduled' || github.run_id }}

if: needs.pre_activation.outputs.check_result == 'success'

timeout-minutes: 60

permissions:
Expand Down Expand Up @@ -77,7 +63,6 @@ checkout:
fetch-depth: 0 # fetch full history

tools:
web-fetch:
github:
toolsets: [all]
min-integrity: none # This workflow is allowed to examine and comment on any issues or PRs
Expand Down Expand Up @@ -188,7 +173,7 @@ safe-outputs:
title-prefix: "[repo-assist] "
labels: [automation, repo-assist]
protected-files:
policy: request_review
policy: request-review
exclude:
- CHANGELOG.md
- README.md
Expand Down Expand Up @@ -219,6 +204,44 @@ safe-outputs:
max: 5
target: "*"

jobs:
safe_outputs:
pre-steps:
- name: Download outputs for PR capacity check
if: github.event_name == 'schedule'
uses: actions/download-artifact@v8
with:
pattern: "{agent,agent-output-fallback}"
merge-multiple: true
path: ${{ runner.temp }}/repo-assist-capacity
- name: Enforce scheduled PR capacity
if: github.event_name == 'schedule'
env:
GH_TOKEN: ${{ github.token }}
OUTPUT_FILE: ${{ runner.temp }}/repo-assist-capacity/agent_output.json
run: |
node <<'EOF'
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const outputs = JSON.parse(fs.readFileSync(process.env.OUTPUT_FILE, "utf8"));
const requested = outputs.items.filter(item => 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:
Expand All @@ -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'
Expand All @@ -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',
Expand Down Expand Up @@ -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():
Expand All @@ -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,
Expand All @@ -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.
Expand Down
Loading