From 8315a306b6a0984aa7a708c9b9803ccb7480b9fc Mon Sep 17 00:00:00 2001 From: Elson Correia <13211046+ECorreia45@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:56:10 -0400 Subject: [PATCH] fix: recognize dependency bots and keep aider cache out of checkout --- .github/maintenance/README.md | 5 +- .github/maintenance/dependency-bots.jq | 5 ++ .github/maintenance/maintenance.sh | 5 +- .../maintenance/test_dependency_selection.py | 87 +++++++++++++++++++ .github/maintenance/test_maintenance.py | 30 +++++++ .github/workflows/daily-ai-maintenance.yml | 5 +- 6 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 .github/maintenance/dependency-bots.jq create mode 100644 .github/maintenance/test_dependency_selection.py diff --git a/.github/maintenance/README.md b/.github/maintenance/README.md index 65b14ef..7988739 100644 --- a/.github/maintenance/README.md +++ b/.github/maintenance/README.md @@ -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. diff --git a/.github/maintenance/dependency-bots.jq b/.github/maintenance/dependency-bots.jq new file mode 100644 index 0000000..9595833 --- /dev/null +++ b/.github/maintenance/dependency-bots.jq @@ -0,0 +1,5 @@ +# gh uses app/; REST fixtures may use [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]"); diff --git a/.github/maintenance/maintenance.sh b/.github/maintenance/maintenance.sh index 5fb6cb6..9f075fa 100644 --- a/.github/maintenance/maintenance.sh +++ b/.github/maintenance/maintenance.sh @@ -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 @@ -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 @@ -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" diff --git a/.github/maintenance/test_dependency_selection.py b/.github/maintenance/test_dependency_selection.py new file mode 100644 index 0000000..f7f8262 --- /dev/null +++ b/.github/maintenance/test_dependency_selection.py @@ -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() diff --git a/.github/maintenance/test_maintenance.py b/.github/maintenance/test_maintenance.py index 4628a69..c1bee97 100644 --- a/.github/maintenance/test_maintenance.py +++ b/.github/maintenance/test_maintenance.py @@ -1,5 +1,6 @@ import importlib.util import os +import shlex from pathlib import Path import subprocess import sys @@ -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') diff --git a/.github/workflows/daily-ai-maintenance.yml b/.github/workflows/daily-ai-maintenance.yml index 6f12875..1d6d81a 100644 --- a/.github/workflows/daily-ai-maintenance.yml +++ b/.github/workflows/daily-ai-maintenance.yml @@ -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 @@ -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