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
5 changes: 4 additions & 1 deletion .github/maintenance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ and dependency-resolution failures remain failures.

The AI dependency-repair path considers up to three failing same-repository bot
PRs. Its source-file allowlist excludes workflows, lockfiles, credentials and
policy files. Protected bytes, tracked file sets, Git HEAD and index are verified.
policy files. Both supported GitHub CLI bot identities (`app/renovate`,
`app/dependabot`) and canonical `[bot]` identities are recognized exactly; bare
usernames are rejected. RepoMap is disabled so aider creates no checkout cache;
explicitly supplied source targets still provide context. Protected bytes, tracked file sets, Git HEAD and index are verified.
Lint, tests and build must pass before any commit is published. Provider failures
(including an exhausted balance with a zero aider exit status) fail the job.

Expand Down
5 changes: 5 additions & 0 deletions .github/maintenance/dependency-bots.jq
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# gh uses app/<slug>; REST fixtures may use <slug>[bot]. Never match bare usernames.
def dependency_bot:
.is_bot != false and
(.login == "app/renovate" or .login == "app/dependabot" or
.login == "renovate[bot]" or .login == "dependabot[bot]");
5 changes: 3 additions & 2 deletions .github/maintenance/maintenance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ set -euo pipefail
helper="${MAINTENANCE_HELPER:?}"
base="${DEFAULT_BRANCH:?}"
export CI=1
# Explicit targets provide context; disable RepoMap so aider creates no checkout cache.

default_checkout() {
git reset --hard HEAD
Expand Down Expand Up @@ -74,7 +75,7 @@ case "${1:?}" in
mapfile -t targets < <(git ls-files | grep -E '^(src/|test/|tests/|scripts/|package\.json$|tsconfig[^/]*\.json$)' | head -n 35 || true)
[ "${#targets[@]}" -gt 0 ] || exit 0
python "$helper/maintenance.py" snapshot "$RUNNER_TEMP/repair-snapshot.json" "${targets[@]}"
if ! python "$helper/maintenance.py" agent aider --yes --no-check-update --no-show-release-notes --no-gitignore --no-auto-commits --no-pretty --no-stream --chat-history-file "$RUNNER_TEMP/repair-history.md" --input-history-file "$RUNNER_TEMP/repair-input" --model gpt-4.1 --message-file "$RUNNER_TEMP/repair.txt" "${targets[@]}"; then
if ! python "$helper/maintenance.py" agent aider --map-tokens 0 --yes --no-check-update --no-show-release-notes --no-gitignore --no-auto-commits --no-pretty --no-stream --chat-history-file "$RUNNER_TEMP/repair-history.md" --input-history-file "$RUNNER_TEMP/repair-input" --model gpt-4.1 --message-file "$RUNNER_TEMP/repair.txt" "${targets[@]}"; then
touch "$RUNNER_TEMP/maintenance-provider-failed"
exit 1
fi
Expand Down Expand Up @@ -111,7 +112,7 @@ case "${1:?}" in
git checkout -b "$branch"
echo 'Implement ONE focused code-quality, test, documentation, or security-hardening improvement in the provided files. Do not invent vulnerabilities, alter public APIs, workflows, dependencies, secrets, or policies. If nothing useful can be improved, do not change files.' > "$RUNNER_TEMP/improvement.txt"
python "$helper/maintenance.py" snapshot "$RUNNER_TEMP/improvement-snapshot.json" "${targets[@]}"
python "$helper/maintenance.py" agent aider --yes --no-check-update --no-show-release-notes --no-gitignore --no-auto-commits --no-pretty --no-stream --chat-history-file "$RUNNER_TEMP/improvement-history.md" --input-history-file "$RUNNER_TEMP/improvement-input" --model gpt-4.1 --message-file "$RUNNER_TEMP/improvement.txt" "${targets[@]}"
python "$helper/maintenance.py" agent aider --map-tokens 0 --yes --no-check-update --no-show-release-notes --no-gitignore --no-auto-commits --no-pretty --no-stream --chat-history-file "$RUNNER_TEMP/improvement-history.md" --input-history-file "$RUNNER_TEMP/improvement-input" --model gpt-4.1 --message-file "$RUNNER_TEMP/improvement.txt" "${targets[@]}"
python "$helper/maintenance.py" verify "$RUNNER_TEMP/improvement-snapshot.json"
git diff --quiet HEAD && exit 0
python "$helper/maintenance.py" validation-snapshot "$RUNNER_TEMP/validation-snapshot.json"
Expand Down
87 changes: 87 additions & 0 deletions .github/maintenance/test_dependency_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Exercise the actual workflow selectors with local gh/bash fixtures only."""
import copy
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest

import yaml

HELPER = Path(__file__).resolve().parent
WORKFLOW = HELPER / 'daily-ai-maintenance.yml'
if not WORKFLOW.exists():
WORKFLOW = HELPER.parent / 'workflows' / 'daily-ai-maintenance.yml'


class DependencySelectionTests(unittest.TestCase):
def fixture(self, number=1, author='app/renovate', conclusion='SUCCESS'):
return {'number': number, 'author': {'login': author}, 'title': 'chore(deps): update patch',
'isDraft': False, 'mergeable': 'MERGEABLE', 'reviewDecision': 'APPROVED',
'statusCheckRollup': [{'status': 'COMPLETED', 'conclusion': conclusion}], 'labels': []}

def selected(self, mode, prs):
name = {'merge': 'Merge fully green low-risk dependency PRs',
'repair': 'Repair up to three failing dependency PRs'}[mode]
workflow = yaml.safe_load(WORKFLOW.read_text())
script = next(step['run'] for step in workflow['jobs']['maintain']['steps'] if step.get('name') == name)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'fixtures.json').write_text(json.dumps(prs))
(root / 'selected').write_text('')
stubs = {
'gh': 'case "$1 $2" in\n "pr list") cat "$FIXTURES";;\n "pr merge") echo "$3" >> "$SELECTED";;\n *) exit 9;;\nesac\n',
'bash': '[[ "$2" == dependency ]] || exit 9\necho "$3" >> "$SELECTED"\n',
}
for name, body in stubs.items():
path = root / name
path.write_text('#!/bin/bash\nset -euo pipefail\n' + body)
path.chmod(0o755)
environment = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}',
MAINTENANCE_HELPER=str(HELPER), RUNNER_TEMP=str(root),
GITHUB_STEP_SUMMARY=str(root / 'summary'),
OPENAI_API_KEY='fixture-not-a-key', FIXTURES=str(root / 'fixtures.json'),
SELECTED=str(root / 'selected'))
# Isolate the existing merge step's fixed temp filename between tests.
script = script.replace('/tmp/prs.json', '"$RUNNER_TEMP/prs.json"')
result = subprocess.run(['/bin/bash', '-c', script], cwd=root, env=environment,
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
return [int(n) for n in (root / 'selected').read_text().splitlines()]

def test_exact_cli_and_canonical_bot_identities_in_both_paths(self):
for author in ['app/renovate', 'app/dependabot', 'renovate[bot]', 'dependabot[bot]']:
for mode, conclusion in [('merge', 'SUCCESS'), ('repair', 'FAILURE')]:
with self.subTest(author=author, mode=mode):
self.assertEqual(self.selected(mode, [self.fixture(author=author, conclusion=conclusion)]), [1])

def test_bare_names_lookalikes_and_nonbots_rejected(self):
for mode, conclusion in [('merge', 'SUCCESS'), ('repair', 'FAILURE')]:
prs = [self.fixture(n, author, conclusion) for n, author in enumerate(
['renovate', 'dependabot', 'app/renovate-extra', 'user/renovate', 'Renovate[bot]', '', None], 1)]
nonbot = self.fixture(90, 'app/renovate', conclusion)
nonbot['author']['is_bot'] = False
prs.extend([nonbot, dict(self.fixture(91, conclusion=conclusion), author=None)])
self.assertEqual(self.selected(mode, prs), [])

def test_merge_review_and_health_guards_preserved(self):
prs = [self.fixture()]
overrides = [{'isDraft': True}, {'mergeable': 'CONFLICTING'}, {'statusCheckRollup': []},
{'reviewDecision': 'CHANGES_REQUESTED'}, {'reviewDecision': 'REVIEW_REQUIRED'},
{'labels': [{'name': 'ai:review-required'}]}, {'title': 'major dependency update'},
{'statusCheckRollup': [{'status': 'COMPLETED', 'conclusion': 'FAILURE'}]},
{'statusCheckRollup': [{'status': 'IN_PROGRESS', 'conclusion': None}]}]
for number, override in enumerate(overrides, 2):
prs.append(dict(self.fixture(number), **copy.deepcopy(override)))
self.assertEqual(self.selected('merge', prs), [1])

def test_repair_requires_failure_nondraft_and_caps_at_three(self):
prs = [self.fixture(n, conclusion='FAILURE') for n in [8, 4, 6, 2]]
prs.extend([dict(self.fixture(1, conclusion='FAILURE'), isDraft=True), self.fixture(3),
self.fixture(7, conclusion='TIMED_OUT')])
self.assertEqual(self.selected('repair', prs), [2, 4, 6])


if __name__ == '__main__':
unittest.main()
30 changes: 30 additions & 0 deletions .github/maintenance/test_maintenance.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import importlib.util
import os
import shlex
from pathlib import Path
import subprocess
import sys
Expand Down Expand Up @@ -122,6 +123,35 @@ def test_source_target_can_change(self):
self.write('src/index.ts', 'export const value = 2\n')
m.verify_snapshot(self.snapshot)

def test_aider_invocations_accept_source_edits_without_repo_map_cache(self):
# Model the pinned aider side effect, using flags from both actual calls.
shell = Path(__file__).with_name('maintenance.sh').read_text()
calls = [shlex.split(line) for line in shell.splitlines() if ' agent aider ' in line]
self.assertEqual(len(calls), 2)
script = (
'from pathlib import Path; import sys; '
'args=sys.argv[1:]; '
'disabled="--map-tokens" in args and args[args.index("--map-tokens")+1] == "0"; '
'None if disabled else Path(".aider.tags.cache.v4").mkdir(); '
'Path("src/index.ts").write_text("export const value = 2\\n")'
)
for call in calls:
with self.subTest(call=call):
self.git('restore', 'src/index.ts')
m.snapshot(self.snapshot, ['src/index.ts'])
flags = call[call.index('aider') + 1:call.index('--chat-history-file')]
m.checked_agent([sys.executable, '-c', script, *flags])
m.verify_snapshot(self.snapshot)
self.assertEqual(m.changes(), {'src/index.ts'})
self.assertFalse(Path('.aider.tags.cache.v4').exists())

def test_unexpected_aider_cache_is_still_rejected(self):
m.snapshot(self.snapshot, ['src/index.ts'])
self.write('src/index.ts', 'export const value = 2\n')
self.write('.aider.tags.cache.v4/cache.db', 'unexpected cache')
with self.assertRaisesRegex(RuntimeError, 'unexpected files'):
m.verify_snapshot(self.snapshot)

def test_protected_workflow_change_is_rejected(self):
m.snapshot(self.snapshot, ['src/index.ts'])
self.write('.github/workflows/test.yml', 'on: pull_request\njobs: {}\n')
Expand Down
5 changes: 2 additions & 3 deletions .github/workflows/daily-ai-maintenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,8 @@ jobs:
gh pr list --state open --limit 100 --json number,title,author,isDraft,mergeable,statusCheckRollup,labels,reviewDecision > /tmp/prs.json
jq -c '.[]' /tmp/prs.json | while IFS= read -r pr; do
n=$(jq -r '.number' <<< "$pr")
author=$(jq -r '.author.login // ""' <<< "$pr")
title=$(jq -r '.title' <<< "$pr")
[[ "$author" == 'renovate[bot]' || "$author" == 'dependabot[bot]' ]] || continue
jq -e -L "$MAINTENANCE_HELPER" 'include "dependency-bots"; .author | dependency_bot' <<< "$pr" >/dev/null || continue
if jq -e '.isDraft or .mergeable != "MERGEABLE" or (.reviewDecision == "CHANGES_REQUESTED") or (.reviewDecision == "REVIEW_REQUIRED") or (.statusCheckRollup | length == 0) or any(.statusCheckRollup[]; .status != "COMPLETED" or .conclusion != "SUCCESS") or any(.labels[]?; .name == "ai:review-required")' <<< "$pr" >/dev/null; then continue; fi
if grep -Eiq '(major|breaking|\bv[0-9]+\b)' <<< "$title"; then continue; fi
if gh pr merge "$n" --squash; then echo "Merged dependency PR #$n" >> "$GITHUB_STEP_SUMMARY"; fi
Expand Down Expand Up @@ -96,7 +95,7 @@ jobs:
set -euo pipefail
[ -n "$OPENAI_API_KEY" ] || { echo 'AI repairs skipped: OPENAI_API_KEY is not configured' >> "$GITHUB_STEP_SUMMARY"; exit 0; }
gh pr list --state open --limit 100 --json number,author,isDraft,statusCheckRollup > "$RUNNER_TEMP/repair-prs.json"
mapfile -t prs < <(jq -r '[.[] | select((.author.login == "renovate[bot]" or .author.login == "dependabot[bot]") and (.isDraft == false) and any(.statusCheckRollup[]?; .conclusion == "FAILURE" or .conclusion == "TIMED_OUT"))] | sort_by(.number) | .[:3][] | .number' "$RUNNER_TEMP/repair-prs.json")
mapfile -t prs < <(jq -r -L "$MAINTENANCE_HELPER" 'include "dependency-bots"; [.[] | select((.author | dependency_bot) and (.isDraft == false) and any(.statusCheckRollup[]?; .conclusion == "FAILURE" or .conclusion == "TIMED_OUT"))] | sort_by(.number) | .[:3][] | .number' "$RUNNER_TEMP/repair-prs.json")
failed=0
for n in "${prs[@]}"; do
if ! bash "$MAINTENANCE_HELPER/maintenance.sh" dependency "$n"; then
Expand Down
Loading