diff --git a/.github/issue-ci-required-gates.json b/.github/issue-ci-required-gates.json new file mode 100644 index 0000000..67196d9 --- /dev/null +++ b/.github/issue-ci-required-gates.json @@ -0,0 +1,16 @@ +{ + "aw_revision": "67af8d020df3ca2ec4b064289408cfb0c2f8b299", + "workflows": [ + { + "path": ".github/workflows/keep-ci.yml", + "jobs": [ + "fmt, clippy, tests, release contract" + ] + } + ], + "qualification_blockers": [ + "No merged current-head cloud functional/e2e qualification gate; GKE pilot PR #6 is unmerged.", + "No approved affected performance qualification gate. Existing unit/integration and release contract checks do not prove full MR qualification." + ], + "lifecycle_mode": "failure-only" +} diff --git a/.github/workflows/issue-ci.yml b/.github/workflows/issue-ci.yml new file mode 100644 index 0000000..d4e84a8 --- /dev/null +++ b/.github/workflows/issue-ci.yml @@ -0,0 +1,36 @@ +# Trusted default-branch controller only; never executes PR code or artifacts. +name: Issue CI handoff +on: + workflow_run: + workflows: ["keep-ci"] + types: [completed] +permissions: + actions: read + contents: read + pull-requests: read + issues: write +concurrency: + group: issue-ci-handoff + cancel-in-progress: false +jobs: + handoff: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Validate trusted failure-only policy + run: python3 scripts/issue-ci/check-policy.py .github/issue-ci-required-gates.json + - name: Reconcile current PR heads from trusted configuration + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + shell: bash + run: | + set -euo pipefail + python3 scripts/issue-ci/handoff.py \ + --repo "$REPO" --run-id "$RUN_ID" --reconcile \ + --config .github/issue-ci-required-gates.json diff --git a/.github/workflows/keep-ci.yml b/.github/workflows/keep-ci.yml index 93682b6..e10b6a6 100644 --- a/.github/workflows/keep-ci.yml +++ b/.github/workflows/keep-ci.yml @@ -1,6 +1,6 @@ # Pull-request and main CI: format, lint, the workspace tests, and the -# release contract. The issue loop's review step merges a PR only after these -# checks pass, and a PR without checks blocks its issue. +# release contract. CI lifecycle handoff requires the reviewed gate contract; +# independent review after success does not merge or change issue labels. name: keep-ci on: diff --git a/scripts/issue-ci/.gitignore b/scripts/issue-ci/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/scripts/issue-ci/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/issue-ci/README.md b/scripts/issue-ci/README.md new file mode 100644 index 0000000..e471504 --- /dev/null +++ b/scripts/issue-ci/README.md @@ -0,0 +1,48 @@ +# CI lifecycle handoff + +Vendored without modification from `faberline/aw@67af8d020df3ca2ec4b064289408cfb0c2f8b299` +`plugins/faberline/skills/issue-ci/scripts/handoff.py`. SHA-256: +`c67533b39d45ad62d407af3748856be396941647c349b63464424fa6aff1c198`. + +The exact existing required gate is `keep-ci` (`.github/workflows/keep-ci.yml`): +`fmt, clippy, tests, release contract`. This explicit manifest is the reviewed +policy source; visible green checks and branch protection are not inferred. + +## Failure-only rollout + +The guard imports the pinned shared controller's `load_config` and requires +its `evaluate_policy` API, explicit `lifecycle_mode: "failure-only"`, and a +nonempty `qualification_blockers` list. Old controllers, full mode, malformed +or missing policy, and clearing blockers refuse before the privileged call. +Confirmed current-head failures can return doing issues to todo with links. +Success, missing, skipped or pending evidence never writes done; already +done issues remain untouched. All gates and coverage gaps remain explicit. +The live gates above alone do not prove the approved MR policy (affected +unit, functional, e2e, performance). + +- No merged current-head cloud functional/e2e qualification gate; GKE pilot PR #6 is unmerged. +- No approved affected performance qualification gate. Existing unit/integration and release contract checks do not prove full MR qualification. + +Resolve these decisions in a separate reviewed change that supplies real +qualification and updates the explicit gate contract before clearing blockers +or enabling full mode. The controller itself suppresses done for any nonempty +blockers even if a future caller accidentally selects full mode. +No new cloud jobs, GKE triggers, performance runs, or credentials are added. +No docs-only exemption or zero-check pass is assumed. + +AGY owns only todo → doing. CI aggregates every required workflow and job +for the current PR head; failure returns todo with run/job links. This +failure-only rollout never produces done. After separately approved full +qualification, review independently verifies the same head after success +and does not merge. Managed PRs must use +`issue/N` or `agy/issue-N` and include `Closes #N`. Unrelated labels remain. + +The serialized controller reconciles all managed PRs on every wake, reads +only GitHub metadata, and uses latest run attempts. Missing, skipped, stale, +neutral, cancelled, or pending evidence cannot pass. It checks the head +before writes and journals handoffs for deduplication and recovery. Checkout +is the trusted default branch; PR code and artifacts are never executed or +used as permission evidence. The handoff workflow becomes effective only +after merge; this PR does not alter automation schedules. + +Local validation: `python3 -m unittest discover -s scripts/issue-ci/tests -v`. diff --git a/scripts/issue-ci/check-policy.py b/scripts/issue-ci/check-policy.py new file mode 100644 index 0000000..3e6a545 --- /dev/null +++ b/scripts/issue-ci/check-policy.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Validate the trusted failure-only rollout without blocking real failures.""" +import importlib.util +from pathlib import Path +import sys + + +def load_controller(): + path = Path(__file__).with_name('handoff.py') + spec = importlib.util.spec_from_file_location('issue_ci_handoff', path) + controller = importlib.util.module_from_spec(spec) + spec.loader.exec_module(controller) + return controller + + +def check(path): + controller = load_controller() + if not all(callable(getattr(controller, name, None)) for name in ('load_config', 'evaluate_policy')): + raise RuntimeError('failure-only rollout requires the reviewed policy-aware controller') + policy = controller.load_config(path) + if policy.get('lifecycle_mode') != 'failure-only': + raise RuntimeError('this rollout requires explicit lifecycle_mode failure-only') + if not isinstance(policy.get('qualification_blockers'), list) or not policy['qualification_blockers']: + raise RuntimeError('this rollout requires an explicit nonempty qualification_blockers list') + return policy + + +if __name__ == '__main__': + check(sys.argv[1]) diff --git a/scripts/issue-ci/handoff.py b/scripts/issue-ci/handoff.py new file mode 100644 index 0000000..8c7a7db --- /dev/null +++ b/scripts/issue-ci/handoff.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Trusted CI controller. Reads GitHub metadata, never executes PR code/artifacts.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import sys +from urllib.parse import quote + + +def api(path, method='GET', data=None): + command = ['gh', 'api', '--method', method, path] + kwargs = dict(text=True) + if data is not None: + command += ['--input', '-'] + kwargs['input'] = json.dumps(data) + output = subprocess.check_output(command, **kwargs) + return json.loads(output) if output.strip() else None + + +def pages(path, key=None): + items = [] + for page in range(1, 1001): + value = api(f'{path}{"&" if "?" in path else "?"}per_page=100&page={page}') + batch = value[key] if key else value + items.extend(batch) + if len(batch) < 100: + return items + raise RuntimeError('pagination limit reached; refuse partial evidence') + + +def load_config(path): + config = json.loads(Path(path).read_text()) + if not isinstance(config, dict): + raise RuntimeError('gate config must be an object') + mode = config.get('lifecycle_mode', 'full') + if mode not in ('full', 'failure-only'): + raise RuntimeError('lifecycle_mode must be full or failure-only') + blockers = config.get('qualification_blockers', []) + if not isinstance(blockers, list) or not all(isinstance(b, str) and b.strip() for b in blockers): + raise RuntimeError('qualification_blockers must be a list of nonempty strings') + workflows = config.get('workflows') + if not isinstance(workflows, list) or not workflows: + raise RuntimeError('required workflows must be a nonempty list') + paths = [] + for w in workflows: + if not isinstance(w, dict): + raise RuntimeError('each required workflow must be an object') + path, jobs = w.get('path'), w.get('jobs') + if (not isinstance(path, str) or not re.fullmatch(r'\.github/workflows/[\w.-]+\.ya?ml', path) + or not isinstance(jobs, list) or not jobs + or not all(isinstance(j, str) and j.strip() for j in jobs) + or len(set(jobs)) != len(jobs)): + raise RuntimeError('each workflow needs an explicit nonempty unique job-name list') + paths.append(path) + if len(set(paths)) != len(paths): + raise RuntimeError('required workflow paths must be unique') + return config + + +def evaluate(workflows, snapshots): + """Aggregate latest runs of ALL configured workflows; no vacuous all-green.""" + pending, failures, evidence = [], [], [] + for workflow in workflows: + path = workflow['path'] + snapshot = snapshots.get(path) + if not snapshot: + pending.append(f'{path}: missing run') + continue + run, jobs = snapshot + evidence.append(f'{run["id"]}:{run.get("run_attempt", 1)}') + if run['status'] != 'completed': + pending.append(f'{path}: {run["status"]}') + elif run.get('conclusion') in ('failure', 'timed_out', 'action_required', 'startup_failure'): + failures.append(f'{path}: {run["conclusion"]} {run["html_url"]}') + elif run.get('conclusion') != 'success': + pending.append(f'{path}: {run.get("conclusion")} {run["html_url"]}') + for name in workflow['jobs']: + matching = [j for j in jobs if j['name'] == name] + if not matching: + pending.append(f'{path} / {name}: missing job') + for job in matching: + link = job['html_url'] + if job['status'] != 'completed': + pending.append(f'{path} / {name}: {job["status"]} {link}') + elif job.get('conclusion') in ('failure', 'timed_out', 'action_required', 'startup_failure'): + failures.append(f'{path} / {name}: {job["conclusion"]} {link}') + elif job.get('conclusion') != 'success': + # Skips need a separate future policy; never assume justification. + pending.append(f'{path} / {name}: {job.get("conclusion")} {link}') + state = 'todo' if failures else 'pending' if pending else 'done' + return state, failures + pending, sorted(evidence) + + +def evaluate_policy(config, snapshots): + """Missing qualification can never produce done, even in full mode.""" + state, reasons, evidence = evaluate(config['workflows'], snapshots) + if state == 'done': + if config.get('lifecycle_mode', 'full') == 'failure-only': + state = 'pending' + reasons.append('failure-only mode does not authorize done') + if config.get('qualification_blockers', []): + state = 'pending' + reasons.extend(f'qualification blocked: {b}' for b in config['qualification_blockers']) + return state, reasons, evidence + + +def snapshot(repo, sha, config): + root = f'repos/{repo}' + snapshots = {} + for workflow in config['workflows']: + runs = pages(f'{root}/actions/workflows/{quote(workflow["path"].split("/")[-1], safe="")}/runs?head_sha={sha}&event=pull_request', 'workflow_runs') + runs = [r for r in runs if r['head_sha'] == sha and r.get('event') == 'pull_request' and r.get('head_repository', {}).get('full_name') == repo and r.get('path') == workflow['path']] + if not runs: + continue + latest = max(runs, key=lambda r: (r['run_number'], r.get('run_attempt', 1))) + # Run-attempt endpoint prevents jobs from a previous attempt filling holes. + jobs = pages(f'{root}/actions/runs/{latest["id"]}/attempts/{latest.get("run_attempt", 1)}/jobs', 'jobs') + snapshots[workflow['path']] = latest, jobs + return snapshots + + +def process_head(args, config, check_event=True): + root = f'repos/{args.repo}' + pr_path = f'{root}/pulls/{args.pr}' + pr = api(pr_path) + if pr['state'] != 'open' or pr['head']['sha'] != args.head or pr['head']['repo']['full_name'] != args.repo: + raise RuntimeError('closed, foreign, or stale PR head; no mutation') + match = re.fullmatch(r'(?:issue/|agy/issue-)([0-9]+)', pr['head']['ref']) + if not match: + raise RuntimeError('PR branch has no supported issue association') + issue = int(match[1]) + if not re.search(rf'(?im)^Closes #{issue}\s*$', pr.get('body') or ''): + raise RuntimeError('PR body must explicitly link its branch issue with Closes #N') + snapshots = snapshot(args.repo, args.head, config) + state, reasons, evidence = evaluate_policy(config, snapshots) + print(json.dumps(dict(state=state, head=args.head, evidence=evidence, reasons=reasons))) + if not args.apply: + return 0 if state == 'done' else 2 + if check_event: + if args.run_id is None: + raise RuntimeError('--apply requires workflow_run event --run-id') + event_run = api(f'{root}/actions/runs/{args.run_id}') + if event_run['head_sha'] != args.head or event_run.get('event') != 'pull_request' or str(event_run['id']) not in {e.split(':')[0] for e in evidence}: + raise RuntimeError('stale/unconfigured triggering run; no mutation') + if state == 'pending': + return 0 + issue_path = f'{root}/issues/{issue}' + current = api(issue_path) + labels = {l['name'] for l in current['labels']} + if current['state'] != 'open' or not labels & {'todo', 'doing', 'done'} or 'blocked' in labels: + raise RuntimeError('issue is closed, blocked, or unmanaged; no mutation') + failure_only = config.get('lifecycle_mode', 'full') == 'failure-only' + if failure_only and 'done' in labels: + return 0 # This mode has no authority over already done issues. + comments = pages(f'{issue_path}/comments') + policy = hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest() + # A failure hands a head back once per coding epoch, regardless of how + # many more workflows fail while AGY is fixing it. A successfully APPLIED + # done receipt begins a new epoch, allowing a later done -> todo regression. + qualified = [c for c in comments + if c['user']['login'] == 'github-actions[bot]' + and c['body'].startswith('') + and f'\nCI: done for PR #{args.pr} head `{args.head}`.\n' in c['body'] + and f'\nPolicy: {policy}\n' in c['body']] + epoch = qualified[-1]['id'] if qualified else 0 + receipt_key = evidence if state == 'done' else ['coding-epoch', epoch] + digest = hashlib.sha256(json.dumps([args.head, receipt_key, config, state], sort_keys=True).encode()).hexdigest() + prefix = f'', 1)[0] if journal else 'pending' + if failure_only and journal is None and labels & {'todo', 'doing', 'done'} != {'doing'}: + return 0 # Only doing -> todo; existing journals may finish recovery. + if phase == 'applied': + print('duplicate applied head/run handoff; no mutation') + return 0 + if phase not in ('pending', 'ready'): + raise RuntimeError('invalid handoff journal phase') + detail = '\n'.join(reasons) if reasons else 'All explicitly required workflow/job gates passed.' + urls = '\n'.join(s[0]['html_url'] for s in snapshots.values()) + + def body(phase): + return f'{prefix}{phase} -->\nCI: {state} for PR #{args.pr} head `{args.head}`.\nPolicy: {policy}\n{detail}\n{urls}' + + def fresh_labels(): + fresh = api(pr_path) + if fresh['state'] != 'open' or fresh['head']['sha'] != args.head: + raise RuntimeError('PR moved during handoff; no further label mutation') + current = api(issue_path) + labels = {l['name'] for l in current['labels']} + if current['state'] != 'open' or 'blocked' in labels or not labels & {'todo', 'doing', 'done'}: + raise RuntimeError('issue lifecycle changed during handoff') + if failure_only and 'done' in labels: + raise RuntimeError('failure-only mode cannot touch a done issue') + return labels + + labels = fresh_labels() + if journal is None: + phase = 'ready' if state in labels else 'pending' + journal = api(f'{issue_path}/comments', 'POST', {'body': body(phase)}) + comment_path = f'{root}/issues/comments/{journal["id"]}' + # A pending intent never suppresses a retry after head/snapshot/API failures. + labels = fresh_labels() + if snapshot(args.repo, args.head, config) != snapshots: + raise RuntimeError('CI runs changed during evaluation; pending intent is retryable') + if phase == 'pending': + # Snapshot pagination can outlast a lifecycle transition. Recheck after + # that read, immediately before adding the target label. + labels = fresh_labels() + if state not in labels: + api(f'{issue_path}/labels', 'POST', {'labels': [state]}) + # Durable proof that the desired label was added BEFORE deleting old + # labels. Workers refuse mixed states, so they cannot claim between + # the add and this checkpoint, even if the controller crashes. + api(comment_path, 'PATCH', {'body': body('ready')}) + labels = fresh_labels() + if state in labels: + for old in sorted((labels & {'todo', 'doing', 'done'}) - {state}): + live = fresh_labels() + if state not in live: + break + if old in live: + api(f'{issue_path}/labels/{old}', 'DELETE') + # ready + desired label absent means a later actor already consumed it + # (e.g. AGY claimed todo -> doing). Never add it again or undo that claim. + api(comment_path, 'PATCH', {'body': body('applied')}) + return 0 + + +def reconcile(args, config): + """Every wake scans all managed open PRs: a replaced pending wake loses none.""" + root = f'repos/{args.repo}' + event = api(f'{root}/actions/runs/{args.run_id}') + if (event.get('event') != 'pull_request' + or event.get('head_repository', {}).get('full_name') != args.repo + or event.get('path') not in {w['path'] for w in config['workflows']}): + raise RuntimeError('unconfigured/foreign reconciliation wake') + errors = [] + for pr in pages(f'{root}/pulls?state=open'): + match = re.fullmatch(r'(?:issue/|agy/issue-)([0-9]+)', pr['head']['ref']) + if (not match or (pr['head'].get('repo') or {}).get('full_name') != args.repo + or not re.search(rf'(?im)^Closes #{int(match[1])}\s*$', pr.get('body') or '')): + continue + current = argparse.Namespace(**vars(args)) + current.pr, current.head, current.apply = pr['number'], pr['head']['sha'], True + try: + # Event metadata only wakes us. All decisions use fresh current-head + # snapshots, including PRs unrelated to the triggering run. + process_head(current, config, check_event=False) + except (RuntimeError, subprocess.CalledProcessError, OSError, ValueError, KeyError, TypeError) as exc: + errors.append(f'PR #{current.pr}: {exc}') + if errors: + raise RuntimeError('; '.join(errors)) + return 0 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--repo', required=True) + parser.add_argument('--config', required=True, help='trusted default-branch config, never from PR') + parser.add_argument('--pr', type=int) + parser.add_argument('--head') + parser.add_argument('--run-id', type=int) + parser.add_argument('--apply', action='store_true') + parser.add_argument('--reconcile', action='store_true', help='trusted wake: reconcile every managed current PR head') + args = parser.parse_args() + if not re.fullmatch(r'[\w.-]+/[\w.-]+', args.repo): + raise RuntimeError('invalid repository') + config = load_config(args.config) + if args.reconcile: + if args.run_id is None or args.pr is not None or args.head is not None: + raise RuntimeError('--reconcile requires --run-id and scans PRs itself') + return reconcile(args, config) + if args.pr is None or not re.fullmatch('[0-9a-f]{40}', args.head or ''): + raise RuntimeError('--pr and valid --head required') + return process_head(args, config) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (RuntimeError, subprocess.CalledProcessError, OSError, ValueError, KeyError, TypeError) as exc: + print(f'refused: {exc}', file=sys.stderr) + sys.exit(2) diff --git a/scripts/issue-ci/tests/test_handoff.py b/scripts/issue-ci/tests/test_handoff.py new file mode 100644 index 0000000..a612e66 --- /dev/null +++ b/scripts/issue-ci/tests/test_handoff.py @@ -0,0 +1,146 @@ +"""Affected controller/policy regression tests; no network or product tests.""" +import hashlib +import importlib.util +import json +import re +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[3] + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +ci = load("handoff", ROOT / "scripts/issue-ci/handoff.py") +guard = load("guard", ROOT / "scripts/issue-ci/check-policy.py") +CONFIG = json.loads((ROOT / ".github/issue-ci-required-gates.json").read_text()) +GATES = CONFIG["workflows"] +SHA = "a" * 40 + +def evidence(): + return {w["path"]: ( + dict(id=i, run_number=i, run_attempt=1, status="completed", conclusion="success", html_url=f"https://github.com/o/r/actions/runs/{i}"), + [dict(name=n, status="completed", conclusion="success", html_url=f"https://github.com/o/r/actions/runs/{i}/job/{j}") for j, n in enumerate(w["jobs"])]) + for i, w in enumerate(GATES, 1)} + +class PolicyTests(unittest.TestCase): + def test_pinned_controller(self): + self.assertEqual(hashlib.sha256((ROOT / "scripts/issue-ci/handoff.py").read_bytes()).hexdigest(), "c67533b39d45ad62d407af3748856be396941647c349b63464424fa6aff1c198") + self.assertEqual(CONFIG["aw_revision"], "67af8d020df3ca2ec4b064289408cfb0c2f8b299") + + def test_failure_only_policy_never_done_even_if_existing_gates_green(self): + guard.check(ROOT / ".github/issue-ci-required-gates.json") + self.assertEqual(CONFIG["lifecycle_mode"], "failure-only") + self.assertTrue(CONFIG["qualification_blockers"]) + self.assertEqual(ci.evaluate_policy(CONFIG, evidence())[0], "pending") + self.assertEqual(ci.evaluate_policy(dict(CONFIG, qualification_blockers=[]), evidence())[0], "pending") + self.assertEqual(ci.evaluate_policy(dict(CONFIG, lifecycle_mode="full"), evidence())[0], "pending") + + def test_guard_refuses_missing_malformed_empty_blockers_and_full_mode(self): + invalid = [dict(CONFIG, qualification_blockers=value) for value in [None, "", [], [None], [" "]]] + missing = dict(CONFIG) + missing.pop("qualification_blockers") + invalid += [missing, dict(CONFIG, lifecycle_mode="full"), dict(CONFIG, workflows=[])] + missing = dict(CONFIG) + missing.pop("lifecycle_mode") + invalid.append(missing) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "policy.json" + for config in invalid: + path.write_text(json.dumps(config)) + with self.subTest(config=config), self.assertRaises(RuntimeError): guard.check(path) + + def test_guard_refuses_old_controller_without_policy_api(self): + from types import SimpleNamespace + with patch.object(guard, "load_controller", return_value=SimpleNamespace(load_config=ci.load_config)): + with self.assertRaises(RuntimeError): guard.check(ROOT / ".github/issue-ci-required-gates.json") + + def test_exact_live_gate_contract(self): + expected = [{'path': '.github/workflows/keep-ci.yml', 'jobs': ['fmt, clippy, tests, release contract']}] + self.assertEqual(GATES, expected) + source = (ROOT / expected[0]["path"]).read_text() + self.assertEqual(re.findall(r"^ name: (.+)$", source, re.M), expected[0]["jobs"]) + workflow = (ROOT / ".github/workflows/issue-ci.yml").read_text() + self.assertIn('workflows: [\"keep-ci\"]', workflow) + self.assertLess(workflow.index("check-policy.py"), workflow.index("handoff.py")) + self.assertIn("ref: ${{ github.event.repository.default_branch }}", workflow) + self.assertIn("persist-credentials: false", workflow) + self.assertNotIn("pull_request_target", workflow) + self.assertNotIn("download-artifact", workflow) + + def test_missing_workflow_or_job_never_passes(self): + self.assertEqual(ci.evaluate_policy(CONFIG, {})[0], "pending") + for index in range(len(GATES[0]["jobs"])): + data = evidence() + data[GATES[0]["path"]][1].pop(index) + self.assertEqual(ci.evaluate_policy(CONFIG, data)[0], "pending") + + def test_skipped_cancelled_neutral_and_pending_never_pass(self): + for conclusion in ["skipped", "cancelled", "neutral", None]: + data = evidence() + data[GATES[0]["path"]][1][0]["conclusion"] = conclusion + self.assertEqual(ci.evaluate_policy(CONFIG, data)[0], "pending") + data = evidence() + data[GATES[0]["path"]][0]["status"] = "in_progress" + self.assertEqual(ci.evaluate_policy(CONFIG, data)[0], "pending") + + def test_failure_returns_todo_and_exact_link(self): + data = evidence() + job = data[GATES[0]["path"]][1][0] + job["conclusion"] = "failure" + state, reasons, _ = ci.evaluate_policy(CONFIG, data) + self.assertEqual(state, "todo") + self.assertIn(job["html_url"], reasons[0]) + + def test_privileged_failure_handoff_and_success_no_writes(self): + from argparse import Namespace + import contextlib + import io + def apply(data, labels): + issue = dict(state="open", labels=[dict(name=name) for name in labels]) + pr = dict(state="open", body="Closes #7", head=dict(sha=SHA, ref="issue/7", repo=dict(full_name="o/r"))) + comments, writes = [], [] + def api(path, method="GET", data=None): + if method == "GET": return pr if "/pulls/" in path else issue + writes.append((path, method, data)) + if method == "POST" and path.endswith("/comments"): + comment = dict(id=1, body=data["body"], user=dict(login="github-actions[bot]")) + comments.append(comment) + return comment + if method == "PATCH": comments[0]["body"] = data["body"] + if method == "POST" and path.endswith("/labels"): + issue["labels"].extend(dict(name=name) for name in data["labels"]) + if method == "DELETE": + issue["labels"] = [label for label in issue["labels"] if label["name"] != path.rsplit("/", 1)[1]] + return {} + args = Namespace(repo="o/r", pr=8, head=SHA, apply=True) + with patch.object(ci, "api", side_effect=api), patch.object(ci, "pages", return_value=comments), patch.object(ci, "snapshot", return_value=data), contextlib.redirect_stdout(io.StringIO()): + ci.process_head(args, CONFIG, check_event=False) + return writes, issue, comments + writes, _, _ = apply(evidence(), {"doing", "priority:p1"}) + self.assertEqual(writes, []) + failed = evidence() + failed[GATES[0]["path"]][1][0]["conclusion"] = "failure" + writes, issue, comments = apply(failed, {"doing", "priority:p1"}) + self.assertEqual({label["name"] for label in issue["labels"]}, {"todo", "priority:p1"}) + self.assertIn(failed[GATES[0]["path"]][1][0]["html_url"], comments[0]["body"]) + writes, _, _ = apply(failed, {"done", "priority:p1"}) + self.assertEqual(writes, []) + + def test_stale_foreign_runs_and_old_attempt_cannot_fill_gaps(self): + run = dict(id=3, run_number=2, run_attempt=2, head_sha=SHA, event="pull_request", status="completed", conclusion="success", html_url="https://github.com/o/r/actions/runs/3", head_repository={"full_name":"o/r"}, path=GATES[0]["path"]) + stale = dict(run, id=100, head_sha="b" * 40) + foreign = dict(run, id=101, head_repository={"full_name":"fork/r"}) + old = dict(run, id=2, run_attempt=1) + with patch.object(ci, "pages", side_effect=[[old, run, stale, foreign], []]) as pages: + result = ci.snapshot("o/r", SHA, {"workflows": [GATES[0]]}) + self.assertEqual(result[GATES[0]["path"]][0]["id"], 3) + self.assertIn("/runs/3/attempts/2/jobs", pages.call_args_list[1].args[0]) + self.assertEqual(ci.evaluate_policy(CONFIG, result)[0], "pending") + +if __name__ == "__main__": unittest.main()