Compare commits

...

1 Commits

Author SHA1 Message Date
Timmy
9312e4dbee fix: #562
Some checks are pending
Agent PR Gate / gate (pull_request) Waiting to run
Agent PR Gate / report (pull_request) Blocked by required conditions
Smoke Test / smoke (pull_request) Waiting to run
2026-04-15 00:31:06 -04:00
5 changed files with 386 additions and 3 deletions

View File

@@ -0,0 +1,97 @@
name: Agent PR Gate
'on':
pull_request:
branches: [main]
jobs:
gate:
runs-on: ubuntu-latest
outputs:
syntax_status: ${{ steps.syntax.outcome }}
tests_status: ${{ steps.tests.outcome }}
criteria_status: ${{ steps.criteria.outcome }}
risk_level: ${{ steps.risk.outputs.level }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install CI dependencies
run: |
python3 -m pip install --quiet pyyaml pytest
- id: risk
name: Classify PR risk
run: |
BASE_REF="${GITHUB_BASE_REF:-main}"
git fetch origin "$BASE_REF" --depth 1
git diff --name-only "origin/$BASE_REF"...HEAD > /tmp/changed_files.txt
python3 scripts/agent_pr_gate.py classify-risk --files-file /tmp/changed_files.txt > /tmp/risk.json
python3 - <<'PY'
import json, os
with open('/tmp/risk.json', 'r', encoding='utf-8') as fh:
data = json.load(fh)
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh:
fh.write('level=' + data['risk'] + '\n')
PY
- id: syntax
name: Syntax and parse checks
continue-on-error: true
run: |
find . \( -name '*.yml' -o -name '*.yaml' \) | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
find . -name '*.json' | while read f; do python3 -m json.tool "$f" > /dev/null || exit 1; done
find . -name '*.py' | xargs -r python3 -m py_compile
find . -name '*.sh' | xargs -r bash -n
- id: tests
name: Test suite
continue-on-error: true
run: |
pytest -q --ignore=uni-wizard/v2/tests/test_author_whitelist.py
- id: criteria
name: PR criteria verification
continue-on-error: true
run: |
python3 scripts/agent_pr_gate.py validate-pr --event-path "$GITHUB_EVENT_PATH"
- name: Fail gate if any required check failed
if: steps.syntax.outcome != 'success' || steps.tests.outcome != 'success' || steps.criteria.outcome != 'success'
run: exit 1
report:
needs: gate
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Post PR gate report
env:
GITEA_TOKEN: ${{ github.token }}
run: |
python3 scripts/agent_pr_gate.py comment \
--event-path "$GITHUB_EVENT_PATH" \
--token "$GITEA_TOKEN" \
--syntax "${{ needs.gate.outputs.syntax_status }}" \
--tests "${{ needs.gate.outputs.tests_status }}" \
--criteria "${{ needs.gate.outputs.criteria_status }}" \
--risk "${{ needs.gate.outputs.risk_level }}"
- name: Auto-merge low-risk clean PRs
if: needs.gate.result == 'success' && needs.gate.outputs.risk_level == 'low'
env:
GITEA_TOKEN: ${{ github.token }}
run: |
python3 scripts/agent_pr_gate.py merge \
--event-path "$GITHUB_EVENT_PATH" \
--token "$GITEA_TOKEN"

View File

@@ -1,5 +1,5 @@
name: Smoke Test
on:
'on':
pull_request:
push:
branches: [main]
@@ -11,10 +11,13 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install parse dependencies
run: |
python3 -m pip install --quiet pyyaml
- name: Parse check
run: |
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
find . \( -name '*.yml' -o -name '*.yaml' \) | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
find . -name '*.json' | while read f; do python3 -m json.tool "$f" > /dev/null || exit 1; done
find . -name '*.py' | xargs -r python3 -m py_compile
find . -name '*.sh' | xargs -r bash -n
echo "PASS: All files parse"

191
scripts/agent_pr_gate.py Executable file
View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
import argparse
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
LOW_RISK_PREFIXES = (
'docs/', 'reports/', 'notes/', 'tickets/', 'research/', 'briefings/',
'twitter-archive/notes/', 'tests/'
)
LOW_RISK_SUFFIXES = {'.md', '.txt', '.jsonl'}
MEDIUM_RISK_PREFIXES = ('.gitea/workflows/',)
HIGH_RISK_PREFIXES = (
'scripts/', 'deploy/', 'infrastructure/', 'metrics/', 'heartbeat/',
'wizards/', 'evennia/', 'uniwizard/', 'uni-wizard/', 'timmy-local/',
'evolution/'
)
HIGH_RISK_SUFFIXES = {'.py', '.sh', '.ini', '.service'}
def read_changed_files(path):
return [line.strip() for line in Path(path).read_text(encoding='utf-8').splitlines() if line.strip()]
def classify_risk(files):
if not files:
return 'high'
level = 'low'
for file_path in files:
path = file_path.strip()
suffix = Path(path).suffix.lower()
if path.startswith(LOW_RISK_PREFIXES):
continue
if path.startswith(HIGH_RISK_PREFIXES) or suffix in HIGH_RISK_SUFFIXES:
return 'high'
if path.startswith(MEDIUM_RISK_PREFIXES):
level = 'medium'
continue
if path.startswith(LOW_RISK_PREFIXES) or suffix in LOW_RISK_SUFFIXES:
continue
level = 'high'
return level
def validate_pr_body(title, body):
details = []
combined = f"{title}\n{body}".strip()
if not re.search(r'#\d+', combined):
details.append('PR body/title must include an issue reference like #562.')
if not re.search(r'(^|\n)\s*(verification|tests?)\s*:', body, re.IGNORECASE):
details.append('PR body must include a Verification: section.')
return (len(details) == 0, details)
def build_comment_body(syntax_status, tests_status, criteria_status, risk_level):
statuses = {
'syntax': syntax_status,
'tests': tests_status,
'criteria': criteria_status,
}
all_clean = all(value == 'success' for value in statuses.values())
action = 'auto-merge' if all_clean and risk_level == 'low' else 'human review'
lines = [
'## Agent PR Gate',
'',
'| Check | Status |',
'|-------|--------|',
f"| Syntax / parse | {syntax_status} |",
f"| Test suite | {tests_status} |",
f"| PR criteria | {criteria_status} |",
f"| Risk level | {risk_level} |",
'',
]
failed = [name for name, value in statuses.items() if value != 'success']
if failed:
lines.append('### Failure details')
for name in failed:
lines.append(f'- {name} reported failure. Inspect the workflow logs for that step.')
else:
lines.append('All automated checks passed.')
lines.extend([
'',
f'Recommendation: {action}.',
'Low-risk documentation/test-only PRs may be auto-merged. Operational changes stay in human review.',
])
return '\n'.join(lines)
def _read_event(event_path):
data = json.loads(Path(event_path).read_text(encoding='utf-8'))
pr = data.get('pull_request') or {}
repo = (data.get('repository') or {}).get('full_name') or os.environ.get('GITHUB_REPOSITORY')
pr_number = pr.get('number') or data.get('number')
title = pr.get('title') or ''
body = pr.get('body') or ''
return repo, pr_number, title, body
def _request_json(method, url, token, payload=None):
data = None if payload is None else json.dumps(payload).encode('utf-8')
headers = {'Authorization': f'token {token}', 'Content-Type': 'application/json'}
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode('utf-8'))
def post_comment(repo, pr_number, token, body):
url = f'{API_BASE}/repos/{repo}/issues/{pr_number}/comments'
return _request_json('POST', url, token, {'body': body})
def merge_pr(repo, pr_number, token):
url = f'{API_BASE}/repos/{repo}/pulls/{pr_number}/merge'
return _request_json('POST', url, token, {'Do': 'merge'})
def cmd_classify_risk(args):
files = list(args.files or [])
if args.files_file:
files.extend(read_changed_files(args.files_file))
print(json.dumps({'risk': classify_risk(files), 'files': files}, indent=2))
return 0
def cmd_validate_pr(args):
_, _, title, body = _read_event(args.event_path)
ok, details = validate_pr_body(title, body)
if ok:
print('PR body validation passed.')
return 0
for detail in details:
print(detail)
return 1
def cmd_comment(args):
repo, pr_number, _, _ = _read_event(args.event_path)
body = build_comment_body(args.syntax, args.tests, args.criteria, args.risk)
post_comment(repo, pr_number, args.token, body)
print(f'Commented on PR #{pr_number} in {repo}.')
return 0
def cmd_merge(args):
repo, pr_number, _, _ = _read_event(args.event_path)
merge_pr(repo, pr_number, args.token)
print(f'Merged PR #{pr_number} in {repo}.')
return 0
def build_parser():
parser = argparse.ArgumentParser(description='Agent PR CI helpers for timmy-home.')
sub = parser.add_subparsers(dest='command', required=True)
classify = sub.add_parser('classify-risk')
classify.add_argument('--files-file')
classify.add_argument('files', nargs='*')
classify.set_defaults(func=cmd_classify_risk)
validate = sub.add_parser('validate-pr')
validate.add_argument('--event-path', required=True)
validate.set_defaults(func=cmd_validate_pr)
comment = sub.add_parser('comment')
comment.add_argument('--event-path', required=True)
comment.add_argument('--token', required=True)
comment.add_argument('--syntax', required=True)
comment.add_argument('--tests', required=True)
comment.add_argument('--criteria', required=True)
comment.add_argument('--risk', required=True)
comment.set_defaults(func=cmd_comment)
merge = sub.add_parser('merge')
merge.add_argument('--event-path', required=True)
merge.add_argument('--token', required=True)
merge.set_defaults(func=cmd_merge)
return parser
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,68 @@
import pathlib
import sys
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'scripts'))
import agent_pr_gate # noqa: E402
class TestAgentPrGate(unittest.TestCase):
def test_classify_risk_low_for_docs_and_tests_only(self):
level = agent_pr_gate.classify_risk([
'docs/runbook.md',
'reports/daily-summary.md',
'tests/test_agent_pr_gate.py',
])
self.assertEqual(level, 'low')
def test_classify_risk_high_for_operational_paths(self):
level = agent_pr_gate.classify_risk([
'scripts/failover_monitor.py',
'deploy/playbook.yml',
])
self.assertEqual(level, 'high')
def test_validate_pr_body_requires_issue_ref_and_verification(self):
ok, details = agent_pr_gate.validate_pr_body(
'feat: add thing',
'What changed only\n\nNo verification section here.'
)
self.assertFalse(ok)
self.assertIn('issue reference', ' '.join(details).lower())
self.assertIn('verification', ' '.join(details).lower())
def test_validate_pr_body_accepts_issue_ref_and_verification(self):
ok, details = agent_pr_gate.validate_pr_body(
'feat: add thing (#562)',
'Refs #562\n\nVerification:\n- pytest -q\n'
)
self.assertTrue(ok)
self.assertEqual(details, [])
def test_build_comment_body_reports_failures_and_human_review(self):
body = agent_pr_gate.build_comment_body(
syntax_status='success',
tests_status='failure',
criteria_status='success',
risk_level='high',
)
self.assertIn('tests', body.lower())
self.assertIn('failure', body.lower())
self.assertIn('human review', body.lower())
def test_changed_files_file_loader_ignores_blanks(self):
with tempfile.NamedTemporaryFile('w+', delete=False) as handle:
handle.write('docs/one.md\n\nreports/two.md\n')
path = handle.name
try:
files = agent_pr_gate.read_changed_files(path)
finally:
pathlib.Path(path).unlink(missing_ok=True)
self.assertEqual(files, ['docs/one.md', 'reports/two.md'])
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,24 @@
import pathlib
import unittest
import yaml
ROOT = pathlib.Path(__file__).resolve().parents[1]
WORKFLOW = ROOT / '.gitea' / 'workflows' / 'agent-pr-gate.yml'
class TestAgentPrWorkflow(unittest.TestCase):
def test_workflow_exists(self):
self.assertTrue(WORKFLOW.exists(), 'agent-pr-gate workflow should exist')
def test_workflow_has_pr_gate_and_reporting_jobs(self):
data = yaml.safe_load(WORKFLOW.read_text(encoding='utf-8'))
self.assertIn('pull_request', data.get('on', {}))
jobs = data.get('jobs', {})
self.assertIn('gate', jobs)
self.assertIn('report', jobs)
report_steps = jobs['report']['steps']
self.assertTrue(any('Auto-merge low-risk clean PRs' in (step.get('name') or '') for step in report_steps))
if __name__ == '__main__':
unittest.main()