diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 7f1ad6d006..86b536ffcf 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -57,3 +57,56 @@ jobs: - run: >- echo "Review approval remains a separate current-head PR review requirement produced by the authenticated dispatch workflow." + + scopeweave-pr506-calendar-stack: + name: scopeweave-pr506-calendar-stack + if: >- + github.repository == 'ContextualWisdomLab/scopeweave' && + github.event_name == 'pull_request_target' && + github.event.action == 'closed' && + github.event.pull_request.number == 506 && + github.event.pull_request.merged == true + permissions: + checks: read + contents: read + issues: write + pull-requests: read + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Resolve immutable central workflow source + id: source + shell: bash + env: + WORKFLOW_REF: ${{ github.workflow_ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + case "${WORKFLOW_REF}" in + ContextualWisdomLab/.github/.github/workflows/opencode-review.yml@*) ;; + *) echo "unexpected workflow source: ${WORKFLOW_REF}" >&2; exit 1 ;; + esac + [[ "${WORKFLOW_SHA}" =~ ^[0-9a-f]{40}$ ]] + printf 'sha=%s\n' "${WORKFLOW_SHA}" >> "${GITHUB_OUTPUT}" + + - name: Check out trusted central control plane + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ContextualWisdomLab/.github + ref: ${{ steps.source.outputs.sha }} + path: trusted-control-plane + persist-credentials: false + fetch-depth: 1 + + - name: Audit ScopeWeave calendar stack after actual #506 merge + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + python3 + trusted-control-plane/scripts/ci/scopeweave_pr506_calendar_stack.py + --event-path "${GITHUB_EVENT_PATH}" diff --git a/docs/doctoring/scopeweave-pr506-calendar-stack-audit.md b/docs/doctoring/scopeweave-pr506-calendar-stack-audit.md new file mode 100644 index 0000000000..1cef3f23b1 --- /dev/null +++ b/docs/doctoring/scopeweave-pr506-calendar-stack-audit.md @@ -0,0 +1,123 @@ +# ScopeWeave PR #506 merge-triggered calendar stack audit + +Status: proposed organization control-plane automation. + +Cross-repository targets: + +- prerequisite: `ContextualWisdomLab/scopeweave#506`; +- calendar credential domain: `ContextualWisdomLab/scopeweave#539`; +- calendar SQLite persistence: `ContextualWisdomLab/scopeweave#541`. + +## Purpose + +The two calendar-subscription pull requests are stacked on the access-grant +prerequisite rather than directly on protected `develop`. A calendar review +must not be promoted from stale pull-request prose, predecessor checks, or the +mere closing of the prerequisite. The audit therefore observes exactly one +state transition: GitHub reports `pull_request_target.closed` for ScopeWeave +PR #506 and the event's `pull_request.merged` value is the boolean `true`. + +The workflow-level condition is only the first gate. The trusted central +script re-fetches PR #506 through the GitHub API and requires the live object +to remain closed and merged with a valid merge commit. It then re-fetches +protected `develop` and proves that the live merge commit is an ancestor of +that protected head. A stale, forged, manually dispatched, closed-unmerged, or +branch-propagation-incomplete event cannot establish prerequisite authority. + +## Trusted execution boundary + +The entrypoint remains the organization required workflow +`.github/workflows/opencode-review.yml`. The job: + +1. matches the exact repository, event name, action, PR number, and merged + boolean; +2. resolves the immutable required-workflow SHA and rejects an unexpected + workflow source; +3. checks out only `ContextualWisdomLab/.github` at that SHA; +4. never checks out or executes ScopeWeave pull-request content; +5. grants only `contents: read`, `checks: read`, `pull-requests: read`, and + `issues: write`; and +6. uses `issues: write` solely to create or update marker-scoped PR comments. + +The script accepts API paths only under +`/repos/ContextualWisdomLab/scopeweave` plus the fixed GraphQL endpoint used to +page review threads. REST and GraphQL responses, the event file, request +payloads, report size, page count, and request timeout are bounded. API and +transport failures fail the job rather than being represented as passing +evidence. + +## Live evidence collected + +After the prerequisite proof, the script re-reads rather than infers: + +- protected `develop` head and its required status contexts; +- #539 and #541 current head/base refs and SHAs; +- open/closed, merged, Draft, mergeability, and mergeability-state fields; +- all exact-head Check Runs and legacy commit statuses, with latest-record + deduplication and literal `success` required for each protected context; +- all formal reviews, bound to the current head SHA; +- independent current-head approvals separately from author or bot approvals; +- current-head change requests and stale reviewer evidence; and +- every review thread through paginated GraphQL, including its resolved state. + +Pull-request bodies are deliberately excluded from authority because they can +contain superseded SHAs, old workflow run identifiers, or prior review claims. + +## Restack classification + +The audit evaluates four live comparisons: + +1. #506 merge commit → protected `develop`; +2. #506 head → #539 head; +3. protected `develop` → #539 head; and +4. #539 head → #541 head. + +For #539: + +- `restack` means its head does not contain the live protected head and its + bounded semantic diff must be reconciled onto `develop`; +- `retarget` means it already contains protected `develop`, but its PR base + still points to the prerequisite branch; and +- `none` means both ancestry and base are current. + +For #541: + +- `restack-now` means the child no longer targets or contains #539's exact + current head; +- `restack-after-539` means the child is correctly stacked now, but #539 must + first move; and +- `none` means the child remains exact after the parent assessment. + +The review-ready order is always #539, then #541. Each PR must obtain fresh +exact-head checks and a qualifying independent current-head review after its +final reconciliation. Draft removal is a maintainer decision after those +conditions are satisfied; this audit does not remove Draft status. + +## Publication and mutation boundary + +The same combined live report is published to #539 and #541 with target-bound +markers: + +```text + + +``` + +Only a comment authored by `github-actions[bot]` whose body starts with the +exact target marker may be updated. Human comments and the sibling PR's marker +are never modified. Duplicate bot-owned markers fail closed. Repeated delivery +is idempotent: an unchanged report causes no API mutation. + +The automation does not push, rebase, merge, retarget, change Draft state, +submit or dismiss reviews, resolve threads, rerun checks, or enable auto-merge. +It reports the live prerequisite, evidence, reconciliation need, and ordered +next action so the normal guarded maintainer path can continue. + +## Verification + +The focused contract suite covers the merged-only event gate, live merge +revalidation, protected-branch ancestry, restack classification, exact-head +check/review/thread summaries, marker ownership and idempotency, bounded REST +and GraphQL clients, and CLI event binding. The module is held to 100% statement +and branch coverage and complete module/class/function docstrings under the +organization control-plane quality policy. diff --git a/scripts/ci/scopeweave_pr506_calendar_stack.py b/scripts/ci/scopeweave_pr506_calendar_stack.py new file mode 100644 index 0000000000..323c13947a --- /dev/null +++ b/scripts/ci/scopeweave_pr506_calendar_stack.py @@ -0,0 +1,993 @@ +"""Audit the ScopeWeave calendar stack after PR #506 actually merges.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from collections import Counter +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urlsplit +from urllib.request import Request, urlopen + +TARGET_REPOSITORY = "ContextualWisdomLab/scopeweave" +PREREQUISITE_PR = 506 + + +class AuditError(RuntimeError): + """Raised when live input cannot satisfy the bounded audit contract.""" + + +def validate_trigger(payload: Mapping[str, Any]) -> None: + """Require the exact closed-and-merged event for ScopeWeave PR #506.""" + repository = payload.get("repository") + pull_request = payload.get("pull_request") + valid = ( + payload.get("action") == "closed" + and isinstance(repository, Mapping) + and repository.get("full_name") == TARGET_REPOSITORY + and isinstance(pull_request, Mapping) + and pull_request.get("number") == PREREQUISITE_PR + and pull_request.get("merged") is True + ) + if not valid: + raise AuditError("expected ScopeWeave PR #506 actual merged event") + + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_ANCESTRY_OK = frozenset({"ahead", "identical"}) + + +@dataclass(frozen=True) +class RestackAssessment: + """Describe the bounded reconciliation actions and review order.""" + + first_action: str + second_action: str + review_ready_order: tuple[int, int] = (539, 541) + + +def prerequisite_resolved( + prerequisite: Mapping[str, Any], + develop: Mapping[str, Any], + merge_to_develop_status: str, +) -> bool: + """Return whether #506's live merge is present in protected develop.""" + merge_sha = prerequisite.get("merge_commit_sha") + develop_sha = develop.get("sha") + return bool( + prerequisite.get("state") == "closed" + and prerequisite.get("merged") is True + and isinstance(merge_sha, str) + and _SHA_RE.fullmatch(merge_sha) + and develop.get("name") == "develop" + and develop.get("protected") is True + and isinstance(develop_sha, str) + and _SHA_RE.fullmatch(develop_sha) + and merge_to_develop_status in _ANCESTRY_OK + ) + + +def assess_restack( + *, + protected_branch: str, + first_base_ref: str, + develop_to_first_status: str, + first_head_ref: str, + second_base_ref: str, + first_to_second_status: str, +) -> RestackAssessment: + """Classify #539 and #541 reconciliation without mutating either branch.""" + first_contains_develop = develop_to_first_status in _ANCESTRY_OK + if not first_contains_develop: + first_action = "restack" + elif first_base_ref != protected_branch: + first_action = "retarget" + else: + first_action = "none" + + second_contains_first = first_to_second_status in _ANCESTRY_OK + if second_base_ref != first_head_ref or not second_contains_first: + second_action = "restack-now" + elif first_action == "restack": + second_action = "restack-after-539" + else: + second_action = "none" + + return RestackAssessment( + first_action=first_action, + second_action=second_action, + ) + + + +@dataclass(frozen=True) +class CheckSummary: + """Summarize latest exact-commit check evidence and required blockers.""" + + required_states: dict[str, str] = field(default_factory=dict) + counts: dict[str, int] = field(default_factory=dict) + blockers: tuple[str, ...] = () + all_required_passing: bool = False + + +@dataclass(frozen=True) +class ReviewSummary: + """Summarize formal reviews and unresolved inline review threads.""" + + total_submissions: int + current_approvals: tuple[str, ...] + qualifying_independent_approvals: tuple[str, ...] + current_changes_requested: tuple[str, ...] + stale_reviewers: tuple[str, ...] + unresolved_threads: int + + +def _record_id(record: Mapping[str, Any]) -> int: + """Return a sortable numeric identifier for an API record.""" + value = record.get("id") + return value if isinstance(value, int) else -1 + + +def _check_run_state(run: Mapping[str, Any]) -> str: + """Normalize one check-run state without promoting non-success conclusions.""" + if run.get("status") != "completed": + return "pending" + conclusion = run.get("conclusion") + return conclusion if isinstance(conclusion, str) and conclusion else "unknown" + + +def summarize_checks( + *, + required_contexts: Sequence[str], + check_runs: Sequence[Mapping[str, Any]], + statuses: Sequence[Mapping[str, Any]], +) -> CheckSummary: + """Deduplicate check evidence and require literal success for each context.""" + latest_runs: dict[str, Mapping[str, Any]] = {} + for run in check_runs: + name = run.get("name") + if not isinstance(name, str) or not name: + continue + prior = latest_runs.get(name) + if prior is None or _record_id(run) > _record_id(prior): + latest_runs[name] = run + + latest_statuses: dict[str, Mapping[str, Any]] = {} + for status in statuses: + context = status.get("context") + if not isinstance(context, str) or not context: + continue + prior = latest_statuses.get(context) + if prior is None or _record_id(status) > _record_id(prior): + latest_statuses[context] = status + + observed: dict[str, str] = { + name: _check_run_state(run) for name, run in latest_runs.items() + } + for context, status in latest_statuses.items(): + if context not in observed: + state = status.get("state") + observed[context] = state if isinstance(state, str) and state else "unknown" + + required_states = { + context: observed.get(context, "absent") for context in required_contexts + } + blockers = tuple( + f"{context}:{state}" + for context, state in required_states.items() + if state != "success" + ) + return CheckSummary( + required_states=required_states, + counts=dict(Counter(observed.values())), + blockers=blockers, + all_required_passing=not blockers, + ) + + +def _review_login(review: Mapping[str, Any]) -> str | None: + """Return a reviewer's login when the review carries a valid user object.""" + user = review.get("user") + if not isinstance(user, Mapping): + return None + login = user.get("login") + return login if isinstance(login, str) and login else None + + +def _reviewer_is_bot(review: Mapping[str, Any], login: str) -> bool: + """Detect bot reviewers using both GitHub type and conventional suffix.""" + user = review.get("user") + user_type = user.get("type") if isinstance(user, Mapping) else None + return user_type == "Bot" or login.endswith("[bot]") + + +def summarize_reviews( + *, + head_sha: str, + author_login: str, + reviews: Sequence[Mapping[str, Any]], + threads: Sequence[Mapping[str, Any]], +) -> ReviewSummary: + """Bind formal review states to the exact current head and count threads.""" + latest_by_reviewer: dict[str, Mapping[str, Any]] = {} + for review in reviews: + login = _review_login(review) + if login is None: + continue + prior = latest_by_reviewer.get(login) + if prior is None or _record_id(review) > _record_id(prior): + latest_by_reviewer[login] = review + + current_approvals: list[str] = [] + qualifying: list[str] = [] + current_changes: list[str] = [] + stale: list[str] = [] + for login, review in latest_by_reviewer.items(): + state = review.get("state") + current = review.get("commit_id") == head_sha + if not current: + stale.append(login) + continue + if state == "APPROVED": + current_approvals.append(login) + if login != author_login and not _reviewer_is_bot(review, login): + qualifying.append(login) + elif state == "CHANGES_REQUESTED": + current_changes.append(login) + + unresolved = sum(1 for thread in threads if thread.get("isResolved") is not True) + return ReviewSummary( + total_submissions=len(reviews), + current_approvals=tuple(sorted(current_approvals)), + qualifying_independent_approvals=tuple(sorted(qualifying)), + current_changes_requested=tuple(sorted(current_changes)), + stale_reviewers=tuple(sorted(stale)), + unresolved_threads=unresolved, + ) + + +@dataclass(frozen=True) +class DevelopState: + """Represent the live protected integration branch and required contexts.""" + + name: str + sha: str + protected: bool + required_contexts: tuple[str, ...] + + +@dataclass(frozen=True) +class PullRequestState: + """Represent one live pull request without trusting its body text.""" + + number: int + state: str + merged: bool + draft: bool + base_ref: str + base_sha: str + head_ref: str + head_sha: str + merge_commit_sha: str | None + author_login: str + mergeable: bool | None + mergeable_state: str + + +@dataclass(frozen=True) +class PullRequestAudit: + """Combine live pull-request metadata with exact-head evidence.""" + + pull_request: PullRequestState + checks: CheckSummary + reviews: ReviewSummary + + +@dataclass(frozen=True) +class StackAudit: + """Capture the complete merge-triggered ScopeWeave stack observation.""" + + develop: DevelopState + prerequisite: PullRequestState + prerequisite_resolved: bool + merge_to_develop_status: str + first: PullRequestAudit + second: PullRequestAudit + prerequisite_to_first_status: str + develop_to_first_status: str + first_to_second_status: str + restack: RestackAssessment + + +def managed_marker(target_pr: int) -> str: + """Return the stable marker for one managed calendar-stack comment.""" + if target_pr not in (539, 541): + raise AuditError("managed comment target must be #539 or #541") + return f"" + + +def select_managed_comment( + comments: Sequence[Mapping[str, Any]], target_pr: int +) -> int | None: + """Select one exact bot-owned marker comment and fail on ambiguity.""" + marker = managed_marker(target_pr) + matches: list[int] = [] + for comment in comments: + user = comment.get("user") + body = comment.get("body") + comment_id = comment.get("id") + if ( + isinstance(user, Mapping) + and user.get("login") == "github-actions[bot]" + and isinstance(body, str) + and body.startswith(marker) + and isinstance(comment_id, int) + ): + matches.append(comment_id) + if len(matches) > 1: + raise AuditError(f"duplicate managed comments for #{target_pr}") + return matches[0] if matches else None + + +def _short_sha(sha: str | None) -> str: + """Render a validated-looking SHA compactly for operator-facing output.""" + return sha[:12] if isinstance(sha, str) else "none" + + +def _yes_no(value: bool) -> str: + """Render a boolean as an unambiguous operator-facing word.""" + return "yes" if value else "no" + + +def _format_names(values: Sequence[str]) -> str: + """Render a bounded login sequence or an explicit none marker.""" + return ", ".join(f"`{value}`" for value in values) if values else "none" + + +def _format_checks(summary: CheckSummary) -> str: + """Render required check states in their live branch-protection order.""" + if not summary.required_states: + return "- required contexts: none declared" + lines = [ + f" - `{context}`: `{state}`" + for context, state in summary.required_states.items() + ] + blockers = ", ".join(f"`{item}`" for item in summary.blockers) or "none" + return "\n".join(["- required contexts:", *lines, f"- blockers: {blockers}"]) + + +def _format_pr_section(label: str, audit: PullRequestAudit) -> str: + """Render one calendar PR's exact metadata, checks, and review evidence.""" + pr = audit.pull_request + reviews = audit.reviews + return "\n".join( + [ + f"### {label}", + ( + f"- #{pr.number} `{pr.head_ref}@{_short_sha(pr.head_sha)}` → " + f"`{pr.base_ref}@{_short_sha(pr.base_sha)}`" + ), + ( + f"- state: `{pr.state}` · Draft: **{_yes_no(pr.draft)}** · " + f"mergeable: `{pr.mergeable}` / `{pr.mergeable_state}`" + ), + _format_checks(audit.checks), + ( + f"- reviews: formal submissions {reviews.total_submissions}; " + f"current-head approvals {_format_names(reviews.current_approvals)}; " + "qualifying independent approvals " + f"{_format_names(reviews.qualifying_independent_approvals)}; " + "current-head changes requested " + f"{_format_names(reviews.current_changes_requested)}; " + f"stale reviewers {_format_names(reviews.stale_reviewers)}; " + f"unresolved threads {reviews.unresolved_threads}" + ), + ] + ) + + +def _first_action_text(action: str) -> str: + """Translate the #539 restack classification into an imperative.""" + return { + "restack": "reconcile its bounded diff onto live protected `develop`, then retarget", + "retarget": "retarget its base to live protected `develop`; no source rewrite is indicated", + "none": "retain its current head/base; no restack is indicated", + }[action] + + +def _second_action_text(action: str) -> str: + """Translate the #541 restack classification into an imperative.""" + return { + "restack-after-539": "wait for #539's reconciled exact head, then restack onto that head", + "restack-now": "reconcile onto #539's exact current head before review", + "none": "retain the current child stack; re-check only if #539 moves", + }[action] + + +def render_report(audit: StackAudit, *, target_pr: int) -> str: + """Render one idempotent combined report for #539 or #541.""" + marker = managed_marker(target_pr) + prerequisite_word = "resolved" if audit.prerequisite_resolved else "not resolved" + report = "\n".join( + [ + marker, + "## #506 merge-triggered calendar-subscription stack audit", + "", + ( + f"Observed protected `{audit.develop.name}@" + f"{_short_sha(audit.develop.sha)}` (protected: " + f"**{_yes_no(audit.develop.protected)}**)." + ), + ( + f"#506 prerequisite: **{prerequisite_word}** · live state " + f"`{audit.prerequisite.state}` / merged `{audit.prerequisite.merged}` · " + f"merge→develop ancestry `{audit.merge_to_develop_status}`." + ), + ( + "Live ancestry: #506 head→#539 " + f"`{audit.prerequisite_to_first_status}` · develop→#539 " + f"`{audit.develop_to_first_status}` · #539→#541 " + f"`{audit.first_to_second_status}`." + ), + "", + _format_pr_section("Calendar domain", audit.first), + "", + _format_pr_section("Calendar SQLite persistence", audit.second), + "", + "## Restack and next review-ready order", + ( + f"1. **#539 first** — {_first_action_text(audit.restack.first_action)}; " + "rerun exact-head required checks, keep unresolved findings at zero, " + "then remove Draft and request an independent current-head review." + ), + ( + f"2. **#541 second** — {_second_action_text(audit.restack.second_action)}; " + "rerun exact-head required checks after the parent head is stable, " + "then remove Draft and request an independent current-head review." + ), + "", + ( + "No source branch, Draft flag, base, review, or merge state was mutated " + "by this read-mostly audit; only this bot-owned marker comment is managed." + ), + ] + ) + if len(report) > 60_000: + raise AuditError("rendered audit report exceeds the bounded comment size") + return report + +REPOSITORY_API_ROOT = "/repos/ContextualWisdomLab/scopeweave" +_COMPARE_STATES = frozenset({"ahead", "behind", "diverged", "identical"}) + + +def _nested_mapping(value: Any) -> Mapping[str, Any] | None: + """Return a nested mapping while keeping untrusted JSON explicit.""" + return value if isinstance(value, Mapping) else None + + +def _valid_ref(value: Any) -> bool: + """Return whether an API ref is a bounded non-empty string.""" + return isinstance(value, str) and 0 < len(value) <= 255 and "\x00" not in value + + +def parse_pull_request( + payload: Mapping[str, Any], *, expected_number: int +) -> PullRequestState: + """Validate one same-repository pull-request API object.""" + base = _nested_mapping(payload.get("base")) + head = _nested_mapping(payload.get("head")) + user = _nested_mapping(payload.get("user")) + base_repo = _nested_mapping(base.get("repo")) if base else None + head_repo = _nested_mapping(head.get("repo")) if head else None + if ( + base_repo is None + or head_repo is None + or base_repo.get("full_name") != TARGET_REPOSITORY + or head_repo.get("full_name") != TARGET_REPOSITORY + ): + raise AuditError("pull request must preserve a same-repository stack") + + base_ref = base.get("ref") if base else None + base_sha = base.get("sha") if base else None + head_ref = head.get("ref") if head else None + head_sha = head.get("sha") if head else None + merge_commit_sha = payload.get("merge_commit_sha") + mergeable = payload.get("mergeable") + valid = ( + payload.get("number") == expected_number + and isinstance(payload.get("state"), str) + and isinstance(payload.get("merged"), bool) + and isinstance(payload.get("draft"), bool) + and _valid_ref(base_ref) + and isinstance(base_sha, str) + and _SHA_RE.fullmatch(base_sha) + and _valid_ref(head_ref) + and isinstance(head_sha, str) + and _SHA_RE.fullmatch(head_sha) + and ( + merge_commit_sha is None + or ( + isinstance(merge_commit_sha, str) + and _SHA_RE.fullmatch(merge_commit_sha) + ) + ) + and user is not None + and isinstance(user.get("login"), str) + and user.get("login") + and (mergeable is None or isinstance(mergeable, bool)) + and isinstance(payload.get("mergeable_state"), str) + ) + if not valid: + raise AuditError(f"invalid pull request API shape for #{expected_number}") + + return PullRequestState( + number=expected_number, + state=str(payload["state"]), + merged=bool(payload["merged"]), + draft=bool(payload["draft"]), + base_ref=str(base_ref), + base_sha=str(base_sha), + head_ref=str(head_ref), + head_sha=str(head_sha), + merge_commit_sha=(str(merge_commit_sha) if merge_commit_sha else None), + author_login=str(user["login"]), + mergeable=mergeable, + mergeable_state=str(payload["mergeable_state"]), + ) + + +def parse_develop(payload: Mapping[str, Any]) -> DevelopState: + """Validate the exact protected develop branch and required contexts.""" + commit = _nested_mapping(payload.get("commit")) + sha = commit.get("sha") if commit else None + if ( + payload.get("name") != "develop" + or payload.get("protected") is not True + or not isinstance(sha, str) + or not _SHA_RE.fullmatch(sha) + ): + raise AuditError("expected live protected develop branch metadata") + + contexts: tuple[str, ...] = () + protection = _nested_mapping(payload.get("protection")) + required = ( + _nested_mapping(protection.get("required_status_checks")) + if protection + else None + ) + raw_contexts = required.get("contexts") if required else None + if isinstance(raw_contexts, list): + contexts = tuple( + context + for context in raw_contexts + if isinstance(context, str) and context + ) + return DevelopState( + name="develop", + sha=sha, + protected=True, + required_contexts=contexts, + ) + + +def comparison_status(payload: Any) -> str: + """Validate one GitHub compare status used for ancestry decisions.""" + if not isinstance(payload, Mapping) or payload.get("status") not in _COMPARE_STATES: + raise AuditError("invalid GitHub comparison status") + return str(payload["status"]) + + +def _collect_pull_request_audit( + api: Any, + pull_request: PullRequestState, + required_contexts: Sequence[str], +) -> PullRequestAudit: + """Collect exact-head checks, formal reviews, and all review threads.""" + head_sha = pull_request.head_sha + check_runs = api.get_paginated_list( + f"{REPOSITORY_API_ROOT}/commits/{head_sha}/check-runs", + key="check_runs", + ) + statuses = api.get_paginated_list( + f"{REPOSITORY_API_ROOT}/commits/{head_sha}/status", + key="statuses", + ) + reviews = api.get_paginated_list( + f"{REPOSITORY_API_ROOT}/pulls/{pull_request.number}/reviews" + ) + threads = api.review_threads(pull_request.number) + return PullRequestAudit( + pull_request=pull_request, + checks=summarize_checks( + required_contexts=required_contexts, + check_runs=check_runs, + statuses=statuses, + ), + reviews=summarize_reviews( + head_sha=head_sha, + author_login=pull_request.author_login, + reviews=reviews, + threads=threads, + ), + ) + + +def collect_stack_audit(api: Any, event: Mapping[str, Any]) -> StackAudit: + """Re-fetch all live state and produce a deterministic stack audit.""" + validate_trigger(event) + prerequisite = parse_pull_request( + api.get(f"{REPOSITORY_API_ROOT}/pulls/506"), expected_number=506 + ) + if ( + prerequisite.state != "closed" + or prerequisite.merged is not True + or prerequisite.merge_commit_sha is None + ): + raise AuditError("live #506 is not merged; refusing stale event authority") + + develop = parse_develop(api.get(f"{REPOSITORY_API_ROOT}/branches/develop")) + first_pr = parse_pull_request( + api.get(f"{REPOSITORY_API_ROOT}/pulls/539"), expected_number=539 + ) + second_pr = parse_pull_request( + api.get(f"{REPOSITORY_API_ROOT}/pulls/541"), expected_number=541 + ) + + merge_to_develop = comparison_status( + api.get( + f"{REPOSITORY_API_ROOT}/compare/" + f"{prerequisite.merge_commit_sha}...{develop.sha}" + ) + ) + prerequisite_to_first = comparison_status( + api.get( + f"{REPOSITORY_API_ROOT}/compare/" + f"{prerequisite.head_sha}...{first_pr.head_sha}" + ) + ) + develop_to_first = comparison_status( + api.get( + f"{REPOSITORY_API_ROOT}/compare/{develop.sha}...{first_pr.head_sha}" + ) + ) + first_to_second = comparison_status( + api.get( + f"{REPOSITORY_API_ROOT}/compare/{first_pr.head_sha}...{second_pr.head_sha}" + ) + ) + + restack = assess_restack( + protected_branch=develop.name, + first_base_ref=first_pr.base_ref, + develop_to_first_status=develop_to_first, + first_head_ref=first_pr.head_ref, + second_base_ref=second_pr.base_ref, + first_to_second_status=first_to_second, + ) + return StackAudit( + develop=develop, + prerequisite=prerequisite, + prerequisite_resolved=prerequisite_resolved( + { + "state": prerequisite.state, + "merged": prerequisite.merged, + "merge_commit_sha": prerequisite.merge_commit_sha, + }, + { + "name": develop.name, + "protected": develop.protected, + "sha": develop.sha, + }, + merge_to_develop, + ), + merge_to_develop_status=merge_to_develop, + first=_collect_pull_request_audit( + api, first_pr, develop.required_contexts + ), + second=_collect_pull_request_audit( + api, second_pr, develop.required_contexts + ), + prerequisite_to_first_status=prerequisite_to_first, + develop_to_first_status=develop_to_first, + first_to_second_status=first_to_second, + restack=restack, + ) + + +def upsert_report(api: Any, target_pr: int, body: str) -> str: + """Create, update, or preserve one exact bot-owned marker comment.""" + marker = managed_marker(target_pr) + if not body.startswith(marker): + raise AuditError("managed report body is missing its exact marker") + comments = api.get_paginated_list( + f"{REPOSITORY_API_ROOT}/issues/{target_pr}/comments" + ) + comment_id = select_managed_comment(comments, target_pr) + if comment_id is None: + api.post( + f"{REPOSITORY_API_ROOT}/issues/{target_pr}/comments", {"body": body} + ) + return "created" + existing = next( + comment + for comment in comments + if comment.get("id") == comment_id + ) + if existing.get("body") == body: + return "unchanged" + api.patch( + f"{REPOSITORY_API_ROOT}/issues/comments/{comment_id}", {"body": body} + ) + return "updated" + + +def execute( + api: Any, + event: Mapping[str, Any], + *, + summary_path: Any = None, +) -> dict[int, str]: + """Run the bounded audit, publish both comments, and write a step summary.""" + audit = collect_stack_audit(api, event) + reports = { + target: render_report(audit, target_pr=target) for target in (539, 541) + } + results = { + target: upsert_report(api, target, reports[target]) for target in (539, 541) + } + if summary_path is not None: + summary_body = reports[539].split("\n", 1)[1] + with open(summary_path, "a", encoding="utf-8") as summary_file: + summary_file.write(summary_body) + summary_file.write("\n") + return results + +_GITHUB_API_URL = "https://api.github.com" +_MAX_RESPONSE_BYTES = 8 * 1024 * 1024 +_MAX_EVENT_BYTES = 1024 * 1024 +_MAX_PAGES = 100 +_REVIEW_THREADS_QUERY = """ +query ScopeWeaveCalendarReviewThreads( + $owner: String! + $name: String! + $number: Int! + $cursor: String +) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $cursor) { + nodes { + id + isResolved + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +""" + + +class GitHubApi: + """Bounded GitHub REST/GraphQL client for the fixed ScopeWeave audit.""" + + def __init__(self, token: str, *, opener: Any = None, timeout: int = 20) -> None: + """Bind one bearer token, HTTPS opener, and bounded request timeout.""" + if not isinstance(token, str) or not token.strip(): + raise AuditError("GITHUB_TOKEN is required") + if not isinstance(timeout, int) or timeout <= 0 or timeout > 120: + raise AuditError("GitHub API timeout must be between 1 and 120 seconds") + self._token = token + self._opener = opener or urlopen + self._timeout = timeout + + @staticmethod + def _validate_path(path: str) -> None: + """Allow only the fixed ScopeWeave repository namespace or GraphQL.""" + if not isinstance(path, str) or not path or len(path) > 2_048: + raise AuditError("disallowed GitHub API path") + parsed = urlsplit(path) + allowed_repository_path = ( + parsed.path == REPOSITORY_API_ROOT + or parsed.path.startswith(f"{REPOSITORY_API_ROOT}/") + ) + if ( + parsed.scheme + or parsed.netloc + or parsed.fragment + or parsed.path != path.split("?", 1)[0] + or any(ord(character) < 32 for character in path) + or not (allowed_repository_path or parsed.path == "/graphql") + ): + raise AuditError("disallowed GitHub API path") + + def _request( + self, + method: str, + path: str, + payload: Mapping[str, Any] | None = None, + ) -> Any: + """Issue one bounded JSON request and return decoded untrusted JSON.""" + self._validate_path(path) + if method not in {"GET", "POST", "PATCH"}: + raise AuditError("disallowed GitHub API method") + data = None + if payload is not None: + if method == "GET": + raise AuditError("GET requests cannot carry a JSON payload") + data = json.dumps( + payload, + ensure_ascii=True, + separators=(",", ":"), + ).encode("utf-8") + if len(data) > 256 * 1024: + raise AuditError("GitHub API request payload exceeds the bound") + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self._token}", + "User-Agent": "ContextualWisdomLab-scopeweave-pr506-audit/1", + "X-GitHub-Api-Version": "2022-11-28", + } + if data is not None: + headers["Content-Type"] = "application/json" + request = Request( + f"{_GITHUB_API_URL}{path}", + data=data, + headers=headers, + method=method, + ) + try: + with self._opener(request, timeout=self._timeout) as response: + raw = response.read(_MAX_RESPONSE_BYTES + 1) + except HTTPError as exc: + raise AuditError(f"GitHub API HTTP failure ({exc.code})") from exc + except URLError as exc: + raise AuditError("GitHub API transport failure") from exc + except TimeoutError as exc: + raise AuditError("GitHub API request timed out") from exc + if len(raw) > _MAX_RESPONSE_BYTES: + raise AuditError("GitHub API response exceeds the bound") + try: + return json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise AuditError("GitHub API returned invalid JSON") from exc + + def get(self, path: str) -> Any: + """Fetch one fixed-path GitHub API JSON object.""" + return self._request("GET", path) + + def post(self, path: str, payload: Mapping[str, Any]) -> Any: + """Create one bounded GitHub resource under the fixed namespace.""" + return self._request("POST", path, payload) + + def patch(self, path: str, payload: Mapping[str, Any]) -> Any: + """Update one bounded GitHub resource under the fixed namespace.""" + return self._request("PATCH", path, payload) + + def get_paginated_list(self, path: str, *, key: str | None = None) -> list[Any]: + """Read every bounded REST page from either a list or keyed-list response.""" + self._validate_path(path) + collected: list[Any] = [] + for page in range(1, _MAX_PAGES + 1): + separator = "&" if "?" in path else "?" + page_path = f"{path}{separator}{urlencode({'per_page': 100, 'page': page})}" + payload = self.get(page_path) + if key is None: + items = payload + elif isinstance(payload, Mapping): + items = payload.get(key) + else: + items = None + if not isinstance(items, list): + raise AuditError("GitHub paginated response has an invalid list shape") + collected.extend(items) + if len(items) < 100: + return collected + raise AuditError("GitHub pagination exceeded the bounded page count") + + def review_threads(self, pr_number: int) -> list[Mapping[str, Any]]: + """Read all inline review-thread resolution states through GraphQL.""" + if pr_number not in (539, 541): + raise AuditError("review-thread target must be #539 or #541") + cursor: str | None = None + collected: list[Mapping[str, Any]] = [] + for _page in range(_MAX_PAGES): + payload = self.post( + "/graphql", + { + "query": _REVIEW_THREADS_QUERY, + "variables": { + "owner": "ContextualWisdomLab", + "name": "scopeweave", + "number": pr_number, + "cursor": cursor, + }, + }, + ) + if not isinstance(payload, Mapping) or payload.get("errors"): + raise AuditError("GitHub GraphQL review-thread query failed") + data = _nested_mapping(payload.get("data")) + repository = _nested_mapping(data.get("repository")) if data else None + pull_request = ( + _nested_mapping(repository.get("pullRequest")) if repository else None + ) + threads = ( + _nested_mapping(pull_request.get("reviewThreads")) + if pull_request + else None + ) + nodes = threads.get("nodes") if threads else None + page_info = ( + _nested_mapping(threads.get("pageInfo")) if threads else None + ) + if not isinstance(nodes, list) or page_info is None: + raise AuditError("GitHub GraphQL review-thread response is invalid") + for node in nodes: + if not isinstance(node, Mapping): + raise AuditError("GitHub GraphQL review thread is invalid") + collected.append(node) + has_next = page_info.get("hasNextPage") + end_cursor = page_info.get("endCursor") + if has_next is not True: + return collected + if not isinstance(end_cursor, str) or not end_cursor: + raise AuditError("GitHub GraphQL pagination cursor is invalid") + cursor = end_cursor + raise AuditError("GitHub GraphQL pagination exceeded the bounded page count") + + +def _load_event(path: str) -> Mapping[str, Any]: + """Read one bounded GitHub event JSON object from the runner filesystem.""" + try: + with open(path, "rb") as event_file: + raw = event_file.read(_MAX_EVENT_BYTES + 1) + except OSError as exc: + raise AuditError("unable to read GitHub event file") from exc + if len(raw) > _MAX_EVENT_BYTES: + raise AuditError("GitHub event file exceeds the bound") + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise AuditError("GitHub event file contains invalid JSON") from exc + if not isinstance(payload, Mapping): + raise AuditError("GitHub event payload must be a JSON object") + return payload + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the exact merge-event audit as a GitHub Actions command-line entrypoint.""" + parser = argparse.ArgumentParser( + description="Audit ScopeWeave #539/#541 only after PR #506 actually merges." + ) + parser.add_argument("--event-path", required=True) + args = parser.parse_args(argv) + try: + token = os.environ.get("GITHUB_TOKEN", "") + if not token: + raise AuditError("GITHUB_TOKEN is required") + event = _load_event(args.event_path) + api = GitHubApi(token) + results = execute( + api, + event, + summary_path=os.environ.get("GITHUB_STEP_SUMMARY") or None, + ) + print(json.dumps(results, sort_keys=True)) + return 0 + except AuditError as exc: + print(f"scopeweave-pr506-calendar-stack: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/tests/test_scopeweave_pr506_calendar_stack.py b/tests/test_scopeweave_pr506_calendar_stack.py new file mode 100644 index 0000000000..42b4ae9a8e --- /dev/null +++ b/tests/test_scopeweave_pr506_calendar_stack.py @@ -0,0 +1,1144 @@ +from pathlib import Path + +import pytest + +from scripts.ci import scopeweave_pr506_calendar_stack as sut + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "scopeweave_pr506_calendar_stack.py" + + +def event_payload(*, number: int = 506, merged: bool = True, action: str = "closed") -> dict: + return { + "action": action, + "repository": {"full_name": "ContextualWisdomLab/scopeweave"}, + "pull_request": {"number": number, "merged": merged}, + } + + +def test_control_plane_script_exists() -> None: + assert SCRIPT.is_file(), "the trusted event handler must exist" + + +def test_validate_trigger_accepts_only_actual_scopeweave_506_merge() -> None: + sut.validate_trigger(event_payload()) + + +@pytest.mark.parametrize( + "payload", + [ + event_payload(merged=False), + event_payload(number=505), + event_payload(action="opened"), + { + **event_payload(), + "repository": {"full_name": "ContextualWisdomLab/other"}, + }, + ], +) +def test_validate_trigger_rejects_every_non_target_event(payload: dict) -> None: + with pytest.raises(Exception, match="ScopeWeave PR #506 actual merged event"): + sut.validate_trigger(payload) + + +def test_prerequisite_requires_live_merge_inside_protected_develop() -> None: + prerequisite = { + "state": "closed", + "merged": True, + "merge_commit_sha": "a" * 40, + } + develop = {"name": "develop", "protected": True, "sha": "b" * 40} + + assert sut.prerequisite_resolved(prerequisite, develop, "ahead") is True + assert sut.prerequisite_resolved(prerequisite, develop, "identical") is True + assert sut.prerequisite_resolved(prerequisite, develop, "diverged") is False + assert sut.prerequisite_resolved( + prerequisite, {**develop, "protected": False}, "ahead" + ) is False + assert sut.prerequisite_resolved( + {**prerequisite, "merged": False}, develop, "ahead" + ) is False + + +def test_restack_plan_defers_child_when_first_pr_must_reconcile() -> None: + plan = sut.assess_restack( + protected_branch="develop", + first_base_ref="feat/access-grant-domain-413", + develop_to_first_status="diverged", + first_head_ref="calendar-domain", + second_base_ref="calendar-domain", + first_to_second_status="ahead", + ) + + assert plan.first_action == "restack" + assert plan.second_action == "restack-after-539" + assert plan.review_ready_order == (539, 541) + + +def test_restack_plan_distinguishes_retarget_only_from_child_restack() -> None: + plan = sut.assess_restack( + protected_branch="develop", + first_base_ref="feat/access-grant-domain-413", + develop_to_first_status="ahead", + first_head_ref="calendar-domain", + second_base_ref="wrong-parent", + first_to_second_status="diverged", + ) + + assert plan.first_action == "retarget" + assert plan.second_action == "restack-now" + + +def test_restack_plan_is_stable_when_bases_and_ancestry_are_current() -> None: + plan = sut.assess_restack( + protected_branch="develop", + first_base_ref="develop", + develop_to_first_status="ahead", + first_head_ref="calendar-domain", + second_base_ref="calendar-domain", + first_to_second_status="ahead", + ) + + assert plan.first_action == "none" + assert plan.second_action == "none" + + +def test_check_summary_uses_latest_exact_context_and_flags_absent_required() -> None: + summary = sut.summarize_checks( + required_contexts=( + "unit-and-api", + "cloud-e2e", + "Analyze (python)", + "property fuzz", + ), + check_runs=( + { + "id": 1, + "name": "unit-and-api", + "status": "completed", + "conclusion": "failure", + }, + { + "id": 2, + "name": "unit-and-api", + "status": "completed", + "conclusion": "success", + }, + { + "id": 3, + "name": "cloud-e2e", + "status": "in_progress", + "conclusion": None, + }, + ), + statuses=( + {"id": 4, "context": "Analyze (python)", "state": "success"}, + ), + ) + + assert summary.required_states == { + "unit-and-api": "success", + "cloud-e2e": "pending", + "Analyze (python)": "success", + "property fuzz": "absent", + } + assert summary.blockers == ("cloud-e2e:pending", "property fuzz:absent") + assert summary.all_required_passing is False + assert summary.counts["success"] == 2 + + +def test_check_summary_passes_only_when_every_required_context_succeeds() -> None: + summary = sut.summarize_checks( + required_contexts=("unit-and-api",), + check_runs=( + { + "id": 8, + "name": "unit-and-api", + "status": "completed", + "conclusion": "success", + }, + ), + statuses=(), + ) + + assert summary.all_required_passing is True + assert summary.blockers == () + + +def test_review_summary_binds_approval_and_change_request_to_exact_head() -> None: + head_sha = "c" * 40 + summary = sut.summarize_reviews( + head_sha=head_sha, + author_login="cursor[bot]", + reviews=( + { + "id": 1, + "state": "APPROVED", + "commit_id": "d" * 40, + "user": {"login": "alice", "type": "User"}, + }, + { + "id": 2, + "state": "APPROVED", + "commit_id": head_sha, + "user": {"login": "cursor[bot]", "type": "Bot"}, + }, + { + "id": 3, + "state": "APPROVED", + "commit_id": head_sha, + "user": {"login": "bob", "type": "User"}, + }, + { + "id": 4, + "state": "CHANGES_REQUESTED", + "commit_id": head_sha, + "user": {"login": "carol", "type": "User"}, + }, + ), + threads=( + {"id": "resolved", "isResolved": True}, + {"id": "open", "isResolved": False}, + ), + ) + + assert summary.total_submissions == 4 + assert summary.current_approvals == ("bob", "cursor[bot]") + assert summary.qualifying_independent_approvals == ("bob",) + assert summary.current_changes_requested == ("carol",) + assert summary.stale_reviewers == ("alice",) + assert summary.unresolved_threads == 1 + + +def test_review_summary_is_empty_without_formal_reviews_or_threads() -> None: + summary = sut.summarize_reviews( + head_sha="e" * 40, + author_login="cursor[bot]", + reviews=(), + threads=(), + ) + + assert summary.total_submissions == 0 + assert summary.qualifying_independent_approvals == () + assert summary.current_changes_requested == () + assert summary.unresolved_threads == 0 + + +def test_select_managed_comment_updates_only_exact_bot_owned_marker() -> None: + comments = ( + { + "id": 10, + "body": "\nhuman", + "user": {"login": "alice"}, + }, + { + "id": 11, + "body": "\nbot", + "user": {"login": "github-actions[bot]"}, + }, + { + "id": 12, + "body": "\nbot", + "user": {"login": "github-actions[bot]"}, + }, + ) + + assert sut.select_managed_comment(comments, 539) == 12 + assert sut.select_managed_comment(comments, 541) == 11 + + +def test_select_managed_comment_fails_closed_on_duplicate_bot_markers() -> None: + marker = "" + comments = ( + {"id": 1, "body": marker, "user": {"login": "github-actions[bot]"}}, + {"id": 2, "body": marker, "user": {"login": "github-actions[bot]"}}, + ) + + with pytest.raises(Exception, match="duplicate managed comments"): + sut.select_managed_comment(comments, 539) + + +def make_pr_state( + number: int, + *, + head_ref: str, + head_sha: str, + base_ref: str, + base_sha: str, + draft: bool, + merged: bool = False, + state: str = "open", + merge_commit_sha: str | None = None, +) -> object: + return sut.PullRequestState( + number=number, + state=state, + merged=merged, + draft=draft, + base_ref=base_ref, + base_sha=base_sha, + head_ref=head_ref, + head_sha=head_sha, + merge_commit_sha=merge_commit_sha, + author_login="cursor[bot]" if number != 506 else "seonghobae", + mergeable=True, + mergeable_state="blocked" if draft else "clean", + ) + + +def test_render_report_contains_live_stack_evidence_and_order() -> None: + checks = sut.CheckSummary( + required_states={"unit-and-api": "success", "property fuzz": "absent"}, + counts={"success": 3}, + blockers=("property fuzz:absent",), + all_required_passing=False, + ) + reviews = sut.ReviewSummary( + total_submissions=0, + current_approvals=(), + qualifying_independent_approvals=(), + current_changes_requested=(), + stale_reviewers=(), + unresolved_threads=0, + ) + audit = sut.StackAudit( + develop=sut.DevelopState( + name="develop", + sha="b" * 40, + protected=True, + required_contexts=("unit-and-api", "property fuzz"), + ), + prerequisite=make_pr_state( + 506, + head_ref="feat/access-grant-domain-413", + head_sha="a" * 40, + base_ref="develop", + base_sha="1" * 40, + draft=False, + merged=True, + state="closed", + merge_commit_sha="9" * 40, + ), + prerequisite_resolved=True, + merge_to_develop_status="ahead", + first=sut.PullRequestAudit( + pull_request=make_pr_state( + 539, + head_ref="calendar-domain", + head_sha="c" * 40, + base_ref="feat/access-grant-domain-413", + base_sha="a" * 40, + draft=True, + ), + checks=checks, + reviews=reviews, + ), + second=sut.PullRequestAudit( + pull_request=make_pr_state( + 541, + head_ref="calendar-sqlite", + head_sha="d" * 40, + base_ref="calendar-domain", + base_sha="c" * 40, + draft=True, + ), + checks=checks, + reviews=reviews, + ), + prerequisite_to_first_status="ahead", + develop_to_first_status="diverged", + first_to_second_status="ahead", + restack=sut.RestackAssessment( + first_action="restack", + second_action="restack-after-539", + ), + ) + + report = sut.render_report(audit, target_pr=539) + + assert report.startswith( + "" + ) + assert "protected `develop@bbbbbbbbbbbb`" in report + assert "#506 prerequisite: **resolved**" in report + assert ( + "#539 `calendar-domain@cccccccccccc` → " + "`feat/access-grant-domain-413@aaaaaaaaaaaa`" + ) in report + assert "Draft: **yes**" in report + assert "`property fuzz`: `absent`" in report + assert "formal submissions 0" in report + assert "unresolved threads 0" in report + assert "1. **#539 first**" in report + assert "2. **#541 second**" in report + assert "No source branch, Draft flag, base, review, or merge state was mutated" in report + + +def test_required_workflow_has_exact_merged_only_gate_and_trusted_checkout() -> None: + workflow = ROOT / ".github" / "workflows" / "opencode-review.yml" + text = workflow.read_text(encoding="utf-8") + + assert "types: [opened, synchronize, reopened, ready_for_review, closed]" in text + assert "scopeweave-pr506-calendar-stack:" in text + assert "github.repository == 'ContextualWisdomLab/scopeweave'" in text + assert "github.event.action == 'closed'" in text + assert "github.event.pull_request.number == 506" in text + assert "github.event.pull_request.merged == true" in text + assert "repository: ContextualWisdomLab/.github" in text + assert "scripts/ci/scopeweave_pr506_calendar_stack.py" in text + assert "issues: write" in text + assert "pull-requests: read" in text + + +def pr_payload( + number: int, + *, + head_ref: str, + head_sha: str, + base_ref: str, + base_sha: str, + draft: bool, + state: str = "open", + merged: bool = False, + merge_commit_sha: str | None = None, + author: str = "cursor[bot]", +) -> dict: + return { + "number": number, + "state": state, + "merged": merged, + "draft": draft, + "base": { + "ref": base_ref, + "sha": base_sha, + "repo": {"full_name": "ContextualWisdomLab/scopeweave"}, + }, + "head": { + "ref": head_ref, + "sha": head_sha, + "repo": {"full_name": "ContextualWisdomLab/scopeweave"}, + }, + "merge_commit_sha": merge_commit_sha, + "user": {"login": author}, + "mergeable": True, + "mergeable_state": "blocked" if draft else "clean", + } + + +def branch_payload(*, sha: str = "b" * 40, protected: bool = True) -> dict: + return { + "name": "develop", + "commit": {"sha": sha}, + "protected": protected, + "protection": { + "required_status_checks": { + "contexts": ["unit-and-api", "property fuzz"] + } + }, + } + + +def test_parse_live_pr_and_develop_reject_untrusted_shapes() -> None: + parsed_pr = sut.parse_pull_request( + pr_payload( + 539, + head_ref="calendar-domain", + head_sha="c" * 40, + base_ref="feat/access-grant-domain-413", + base_sha="a" * 40, + draft=True, + ), + expected_number=539, + ) + parsed_branch = sut.parse_develop(branch_payload()) + + assert parsed_pr.head_ref == "calendar-domain" + assert parsed_branch.required_contexts == ("unit-and-api", "property fuzz") + + wrong_repo = pr_payload( + 539, + head_ref="calendar-domain", + head_sha="c" * 40, + base_ref="develop", + base_sha="b" * 40, + draft=True, + ) + wrong_repo["head"]["repo"]["full_name"] = "attacker/fork" + with pytest.raises(Exception, match="same-repository"): + sut.parse_pull_request(wrong_repo, expected_number=539) + + with pytest.raises(Exception, match="invalid pull request"): + sut.parse_pull_request( + {**pr_payload( + 539, + head_ref="calendar-domain", + head_sha="bad", + base_ref="develop", + base_sha="b" * 40, + draft=True, + )}, + expected_number=539, + ) + + with pytest.raises(Exception, match="protected develop"): + sut.parse_develop(branch_payload(protected=False)) + + +class FakeApi: + def __init__(self, responses: dict, threads: dict[int, list] | None = None) -> None: + self.responses = responses + self.threads = threads or {} + self.calls: list[tuple] = [] + self.mutations: list[tuple] = [] + + def get(self, path: str) -> object: + self.calls.append(("GET", path)) + value = self.responses[path] + return value() if callable(value) else value + + def get_paginated_list(self, path: str, *, key: str | None = None) -> list: + self.calls.append(("GET_ALL", path, key)) + value = self.responses[path] + if key is not None and isinstance(value, dict): + return list(value[key]) + return list(value) + + def review_threads(self, pr_number: int) -> list: + self.calls.append(("THREADS", pr_number)) + return list(self.threads.get(pr_number, [])) + + def post(self, path: str, payload: dict) -> object: + self.mutations.append(("POST", path, payload)) + return {"id": 999} + + def patch(self, path: str, payload: dict) -> object: + self.mutations.append(("PATCH", path, payload)) + return {"id": int(path.rsplit("/", 1)[1])} + + +def live_api_responses() -> dict: + repo = "/repos/ContextualWisdomLab/scopeweave" + prerequisite_head = "a" * 40 + prerequisite_merge = "9" * 40 + develop_sha = "b" * 40 + first_head = "c" * 40 + second_head = "d" * 40 + return { + f"{repo}/pulls/506": pr_payload( + 506, + head_ref="feat/access-grant-domain-413", + head_sha=prerequisite_head, + base_ref="develop", + base_sha="1" * 40, + draft=False, + state="closed", + merged=True, + merge_commit_sha=prerequisite_merge, + author="seonghobae", + ), + f"{repo}/branches/develop": branch_payload(sha=develop_sha), + f"{repo}/pulls/539": pr_payload( + 539, + head_ref="calendar-domain", + head_sha=first_head, + base_ref="feat/access-grant-domain-413", + base_sha=prerequisite_head, + draft=True, + ), + f"{repo}/pulls/541": pr_payload( + 541, + head_ref="calendar-sqlite", + head_sha=second_head, + base_ref="calendar-domain", + base_sha=first_head, + draft=True, + ), + f"{repo}/compare/{prerequisite_merge}...{develop_sha}": {"status": "ahead"}, + f"{repo}/compare/{prerequisite_head}...{first_head}": {"status": "ahead"}, + f"{repo}/compare/{develop_sha}...{first_head}": {"status": "diverged"}, + f"{repo}/compare/{first_head}...{second_head}": {"status": "ahead"}, + f"{repo}/commits/{first_head}/check-runs": { + "check_runs": [ + { + "id": 1, + "name": "unit-and-api", + "status": "completed", + "conclusion": "success", + } + ] + }, + f"{repo}/commits/{first_head}/status": { + "statuses": [ + {"id": 2, "context": "property fuzz", "state": "success"} + ] + }, + f"{repo}/pulls/539/reviews": [], + f"{repo}/commits/{second_head}/check-runs": { + "check_runs": [ + { + "id": 3, + "name": "unit-and-api", + "status": "completed", + "conclusion": "success", + } + ] + }, + f"{repo}/commits/{second_head}/status": { + "statuses": [ + {"id": 4, "context": "property fuzz", "state": "success"} + ] + }, + f"{repo}/pulls/541/reviews": [], + f"{repo}/issues/539/comments": [], + f"{repo}/issues/541/comments": [], + } + + +def test_collect_stack_audit_refetches_all_live_evidence() -> None: + api = FakeApi(live_api_responses()) + + audit = sut.collect_stack_audit(api, event_payload()) + + assert audit.prerequisite_resolved is True + assert audit.develop.sha == "b" * 40 + assert audit.first.pull_request.head_sha == "c" * 40 + assert audit.second.pull_request.base_sha == "c" * 40 + assert audit.first.checks.all_required_passing is True + assert audit.second.reviews.total_submissions == 0 + assert audit.restack.first_action == "restack" + assert audit.restack.second_action == "restack-after-539" + assert ("THREADS", 539) in api.calls + assert ("THREADS", 541) in api.calls + + +def test_collect_stack_audit_rejects_live_unmerged_prerequisite() -> None: + responses = live_api_responses() + responses["/repos/ContextualWisdomLab/scopeweave/pulls/506"] = { + **responses["/repos/ContextualWisdomLab/scopeweave/pulls/506"], + "state": "open", + "merged": False, + "merge_commit_sha": None, + } + api = FakeApi(responses) + + with pytest.raises(Exception, match="live #506 is not merged"): + sut.collect_stack_audit(api, event_payload()) + + assert not api.mutations + + +def test_upsert_report_creates_updates_and_noops_exact_bot_comment() -> None: + repo = "/repos/ContextualWisdomLab/scopeweave" + marker = sut.managed_marker(539) + + create_api = FakeApi({f"{repo}/issues/539/comments": []}) + assert sut.upsert_report(create_api, 539, marker + "\nnew") == "created" + assert create_api.mutations == [ + ("POST", f"{repo}/issues/539/comments", {"body": marker + "\nnew"}) + ] + + update_api = FakeApi( + { + f"{repo}/issues/539/comments": [ + { + "id": 7, + "body": marker + "\nold", + "user": {"login": "github-actions[bot]"}, + } + ] + } + ) + assert sut.upsert_report(update_api, 539, marker + "\nnew") == "updated" + assert update_api.mutations == [ + ("PATCH", f"{repo}/issues/comments/7", {"body": marker + "\nnew"}) + ] + + noop_api = FakeApi( + { + f"{repo}/issues/539/comments": [ + { + "id": 8, + "body": marker + "\nnew", + "user": {"login": "github-actions[bot]"}, + } + ] + } + ) + assert sut.upsert_report(noop_api, 539, marker + "\nnew") == "unchanged" + assert noop_api.mutations == [] + + +def test_execute_publishes_one_combined_report_to_each_calendar_pr(tmp_path: Path) -> None: + api = FakeApi(live_api_responses()) + summary_path = tmp_path / "summary.md" + + result = sut.execute(api, event_payload(), summary_path=summary_path) + + assert result == {539: "created", 541: "created"} + assert [mutation[1] for mutation in api.mutations] == [ + "/repos/ContextualWisdomLab/scopeweave/issues/539/comments", + "/repos/ContextualWisdomLab/scopeweave/issues/541/comments", + ] + summary = summary_path.read_text(encoding="utf-8") + assert "#506 merge-triggered calendar-subscription stack audit" in summary + assert "target=539" not in summary + + +class FakeHttpResponse: + def __init__(self, payload: object) -> None: + import json + + self.body = json.dumps(payload).encode("utf-8") + + def __enter__(self) -> "FakeHttpResponse": + return self + + def __exit__(self, exc_type, exc, traceback) -> bool: + return False + + def read(self, amount: int = -1) -> bytes: + return self.body if amount < 0 else self.body[:amount] + + +def test_github_api_binds_token_method_payload_and_allowed_path() -> None: + import json + + captured: list[tuple] = [] + + def opener(request, timeout: int): + captured.append((request, timeout)) + return FakeHttpResponse({"ok": True}) + + api = sut.GitHubApi("secret-token", opener=opener, timeout=7) + result = api.post( + "/repos/ContextualWisdomLab/scopeweave/issues/539/comments", + {"body": "hello"}, + ) + + assert result == {"ok": True} + request, timeout = captured[0] + assert timeout == 7 + assert request.method == "POST" + assert request.full_url.endswith("/issues/539/comments") + assert request.get_header("Authorization") == "Bearer secret-token" + assert json.loads(request.data.decode("utf-8")) == {"body": "hello"} + + with pytest.raises(Exception, match="disallowed GitHub API path"): + api.get("/repos/attacker/repo/pulls/1") + + +def test_github_api_paginates_rest_lists_and_keyed_objects() -> None: + import json + from urllib.parse import parse_qs, urlparse + + def opener(request, timeout: int): + del timeout + page = int(parse_qs(urlparse(request.full_url).query)["page"][0]) + if "check-runs" in request.full_url: + payload = { + "check_runs": ( + [{"id": i} for i in range(100)] + if page == 1 + else [{"id": 101}] + ) + } + else: + payload = ([{"id": i} for i in range(100)] if page == 1 else [{"id": 101}]) + return FakeHttpResponse(json.loads(json.dumps(payload))) + + api = sut.GitHubApi("token", opener=opener) + keyed = api.get_paginated_list( + "/repos/ContextualWisdomLab/scopeweave/commits/" + "a" * 40 + "/check-runs", + key="check_runs", + ) + plain = api.get_paginated_list( + "/repos/ContextualWisdomLab/scopeweave/pulls/539/reviews" + ) + + assert len(keyed) == 101 + assert len(plain) == 101 + + +def test_github_api_paginates_graphql_review_threads() -> None: + import json + + requests: list[dict] = [] + + def opener(request, timeout: int): + del timeout + body = json.loads(request.data.decode("utf-8")) + requests.append(body) + cursor = body["variables"].get("cursor") + nodes = ( + [{"id": "first", "isResolved": False}] + if cursor is None + else [{"id": "second", "isResolved": True}] + ) + page_info = ( + {"hasNextPage": True, "endCursor": "cursor-1"} + if cursor is None + else {"hasNextPage": False, "endCursor": None} + ) + return FakeHttpResponse( + { + "data": { + "repository": { + "pullRequest": { + "reviewThreads": { + "nodes": nodes, + "pageInfo": page_info, + } + } + } + } + } + ) + + api = sut.GitHubApi("token", opener=opener) + threads = api.review_threads(539) + + assert [thread["id"] for thread in threads] == ["first", "second"] + assert requests[0]["variables"]["number"] == 539 + assert requests[1]["variables"]["cursor"] == "cursor-1" + + +def test_main_reads_exact_event_file_and_reports_publication( + monkeypatch, tmp_path: Path, capsys +) -> None: + import json + + event_path = tmp_path / "event.json" + event_path.write_text(json.dumps(event_payload()), encoding="utf-8") + summary_path = tmp_path / "summary.md" + fake_api = object() + observed: dict = {} + + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_path)) + monkeypatch.setattr(sut, "GitHubApi", lambda token: fake_api) + + def fake_execute(api, event, *, summary_path): + observed.update(api=api, event=event, summary_path=summary_path) + return {539: "created", 541: "updated"} + + monkeypatch.setattr(sut, "execute", fake_execute) + + assert sut.main(["--event-path", str(event_path)]) == 0 + assert observed == { + "api": fake_api, + "event": event_payload(), + "summary_path": str(summary_path), + } + assert json.loads(capsys.readouterr().out) == { + "539": "created", + "541": "updated", + } + + +def test_main_fails_closed_without_token(monkeypatch, tmp_path: Path, capsys) -> None: + import json + + event_path = tmp_path / "event.json" + event_path.write_text(json.dumps(event_payload()), encoding="utf-8") + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + assert sut.main(["--event-path", str(event_path)]) == 1 + assert "GITHUB_TOKEN is required" in capsys.readouterr().err + + +def test_check_summary_ignores_malformed_and_older_duplicate_evidence() -> None: + summary = sut.summarize_checks( + required_contexts=("shared", "status-only", "unknown-run"), + check_runs=( + {"id": 1, "name": None, "status": "completed", "conclusion": "success"}, + {"id": 5, "name": "shared", "status": "completed", "conclusion": "success"}, + {"id": 4, "name": "shared", "status": "completed", "conclusion": "failure"}, + {"id": 6, "name": "unknown-run", "status": "completed", "conclusion": None}, + ), + statuses=( + {"id": 1, "context": None, "state": "success"}, + {"id": 5, "context": "status-only", "state": None}, + {"id": 4, "context": "status-only", "state": "failure"}, + {"id": 7, "context": "shared", "state": "failure"}, + ), + ) + + assert summary.required_states == { + "shared": "success", + "status-only": "unknown", + "unknown-run": "unknown", + } + + +def test_review_summary_ignores_invalid_reviewers_and_older_duplicate_states() -> None: + head_sha = "a" * 40 + summary = sut.summarize_reviews( + head_sha=head_sha, + author_login="author", + reviews=( + {"id": 1, "state": "APPROVED", "commit_id": head_sha, "user": None}, + { + "id": 5, + "state": "COMMENTED", + "commit_id": head_sha, + "user": {"login": "alice", "type": "User"}, + }, + { + "id": 4, + "state": "APPROVED", + "commit_id": head_sha, + "user": {"login": "alice", "type": "User"}, + }, + { + "id": 6, + "state": "APPROVED", + "commit_id": head_sha, + "user": {"login": "robot[bot]", "type": "User"}, + }, + ), + threads=(), + ) + + assert summary.total_submissions == 4 + assert summary.current_approvals == ("robot[bot]",) + assert summary.qualifying_independent_approvals == () + + +def test_small_formatting_and_validation_fail_closed_paths() -> None: + assert sut._format_checks(sut.CheckSummary()) == "- required contexts: none declared" + with pytest.raises(Exception, match="managed comment target"): + sut.managed_marker(500) + with pytest.raises(Exception, match="invalid GitHub comparison"): + sut.comparison_status({"status": "unknown"}) + + branch = branch_payload() + branch.pop("protection") + assert sut.parse_develop(branch).required_contexts == () + + +def test_render_report_rejects_unbounded_comment() -> None: + from dataclasses import replace + + audit = sut.collect_stack_audit(FakeApi(live_api_responses()), event_payload()) + huge_checks = sut.CheckSummary( + required_states={"x" * 61_000: "success"}, + counts={"success": 1}, + blockers=(), + all_required_passing=True, + ) + audit = replace( + audit, + first=replace(audit.first, checks=huge_checks), + ) + + with pytest.raises(Exception, match="exceeds the bounded comment size"): + sut.render_report(audit, target_pr=539) + + +def test_upsert_report_rejects_unmarked_body_and_execute_supports_no_summary() -> None: + with pytest.raises(Exception, match="missing its exact marker"): + sut.upsert_report(FakeApi({}), 539, "not managed") + + api = FakeApi(live_api_responses()) + assert sut.execute(api, event_payload(), summary_path=None) == { + 539: "created", + 541: "created", + } + + +class RawHttpResponse: + def __init__(self, body: bytes) -> None: + self.body = body + + def __enter__(self) -> "RawHttpResponse": + return self + + def __exit__(self, exc_type, exc, traceback) -> bool: + return False + + def read(self, amount: int = -1) -> bytes: + return self.body if amount < 0 else self.body[:amount] + + +@pytest.mark.parametrize("token", ["", " ", None]) +def test_github_api_rejects_missing_tokens(token) -> None: + with pytest.raises(Exception, match="GITHUB_TOKEN is required"): + sut.GitHubApi(token) + + +@pytest.mark.parametrize("timeout", [0, 121, "20"]) +def test_github_api_rejects_invalid_timeouts(timeout) -> None: + with pytest.raises(Exception, match="timeout must be between"): + sut.GitHubApi("token", timeout=timeout) + + +@pytest.mark.parametrize("path", ["", None, "/" + "x" * 2_048]) +def test_github_api_rejects_malformed_paths(path) -> None: + api = sut.GitHubApi("token", opener=lambda request, timeout: None) + with pytest.raises(Exception, match="disallowed GitHub API path"): + api.get(path) + + +def test_github_api_rejects_methods_get_payloads_and_oversized_payloads() -> None: + api = sut.GitHubApi("token", opener=lambda request, timeout: None) + path = "/repos/ContextualWisdomLab/scopeweave/pulls/539" + with pytest.raises(Exception, match="disallowed GitHub API method"): + api._request("DELETE", path) + with pytest.raises(Exception, match="GET requests cannot carry"): + api._request("GET", path, {"unexpected": True}) + with pytest.raises(Exception, match="payload exceeds"): + api.post( + "/repos/ContextualWisdomLab/scopeweave/issues/539/comments", + {"body": "x" * (300 * 1024)}, + ) + + +def test_github_api_patch_uses_patch_method() -> None: + captured = [] + + def opener(request, timeout): + captured.append((request.method, timeout)) + return FakeHttpResponse({"ok": True}) + + api = sut.GitHubApi("token", opener=opener) + assert api.patch( + "/repos/ContextualWisdomLab/scopeweave/issues/comments/7", + {"body": "updated"}, + ) == {"ok": True} + assert captured == [("PATCH", 20)] + + +def test_github_api_normalizes_transport_and_response_failures(monkeypatch) -> None: + from urllib.error import HTTPError, URLError + + path = "/repos/ContextualWisdomLab/scopeweave/pulls/539" + + failures = ( + ( + lambda request, timeout: (_ for _ in ()).throw( + HTTPError(request.full_url, 403, "forbidden", None, None) + ), + r"HTTP failure \(403\)", + ), + (lambda request, timeout: (_ for _ in ()).throw(URLError("offline")), "transport failure"), + (lambda request, timeout: (_ for _ in ()).throw(TimeoutError()), "timed out"), + ) + for opener, message in failures: + with pytest.raises(Exception, match=message): + sut.GitHubApi("token", opener=opener).get(path) + + monkeypatch.setattr(sut, "_MAX_RESPONSE_BYTES", 4) + with pytest.raises(Exception, match="response exceeds"): + sut.GitHubApi("token", opener=lambda request, timeout: RawHttpResponse(b"12345")).get(path) + + monkeypatch.setattr(sut, "_MAX_RESPONSE_BYTES", 128) + with pytest.raises(Exception, match="invalid JSON"): + sut.GitHubApi( + "token", + opener=lambda request, timeout: RawHttpResponse(b"not-json"), + ).get(path) + + +def test_github_api_rejects_invalid_rest_pages_and_page_exhaustion(monkeypatch) -> None: + path = "/repos/ContextualWisdomLab/scopeweave/commits/" + "a" * 40 + "/check-runs" + + api = sut.GitHubApi("token", opener=lambda request, timeout: FakeHttpResponse([])) + with pytest.raises(Exception, match="invalid list shape"): + api.get_paginated_list(path, key="check_runs") + + monkeypatch.setattr(sut, "_MAX_PAGES", 1) + api = sut.GitHubApi( + "token", + opener=lambda request, timeout: FakeHttpResponse( + {"check_runs": [{"id": value} for value in range(100)]} + ), + ) + with pytest.raises(Exception, match="pagination exceeded"): + api.get_paginated_list(path, key="check_runs") + + +def graphql_payload(*, nodes=None, has_next=False, cursor=None) -> dict: + return { + "data": { + "repository": { + "pullRequest": { + "reviewThreads": { + "nodes": [] if nodes is None else nodes, + "pageInfo": { + "hasNextPage": has_next, + "endCursor": cursor, + }, + } + } + } + } + } + + +def test_github_api_rejects_invalid_review_thread_targets_and_shapes() -> None: + with pytest.raises(Exception, match="target must be"): + sut.GitHubApi("token", opener=lambda request, timeout: None).review_threads(500) + + responses = ( + ({"errors": [{"message": "no"}]}, "query failed"), + ({"data": {}}, "response is invalid"), + (graphql_payload(nodes=[1]), "review thread is invalid"), + (graphql_payload(has_next=True, cursor=None), "cursor is invalid"), + ) + for payload, message in responses: + api = sut.GitHubApi( + "token", opener=lambda request, timeout, value=payload: FakeHttpResponse(value) + ) + with pytest.raises(Exception, match=message): + api.review_threads(539) + + +def test_github_api_rejects_graphql_page_exhaustion(monkeypatch) -> None: + monkeypatch.setattr(sut, "_MAX_PAGES", 1) + api = sut.GitHubApi( + "token", + opener=lambda request, timeout: FakeHttpResponse( + graphql_payload(has_next=True, cursor="next") + ), + ) + with pytest.raises(Exception, match="GraphQL pagination exceeded"): + api.review_threads(539) + + +def test_load_event_rejects_unreadable_oversized_invalid_and_nonobject_files( + monkeypatch, tmp_path: Path +) -> None: + missing = tmp_path / "missing.json" + with pytest.raises(Exception, match="unable to read"): + sut._load_event(str(missing)) + + monkeypatch.setattr(sut, "_MAX_EVENT_BYTES", 4) + oversized = tmp_path / "oversized.json" + oversized.write_bytes(b"12345") + with pytest.raises(Exception, match="exceeds the bound"): + sut._load_event(str(oversized)) + + monkeypatch.setattr(sut, "_MAX_EVENT_BYTES", 128) + invalid = tmp_path / "invalid.json" + invalid.write_text("not-json", encoding="utf-8") + with pytest.raises(Exception, match="invalid JSON"): + sut._load_event(str(invalid)) + + nonobject = tmp_path / "list.json" + nonobject.write_text("[]", encoding="utf-8") + with pytest.raises(Exception, match="must be a JSON object"): + sut._load_event(str(nonobject)) + + +def test_module_entrypoint_exits_through_main(monkeypatch, tmp_path: Path) -> None: + import runpy + import sys + + event_path = tmp_path / "event.json" + event_path.write_text("{}", encoding="utf-8") + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setattr(sys, "argv", [str(SCRIPT), "--event-path", str(event_path)]) + + with pytest.raises(SystemExit) as error: + runpy.run_path(str(SCRIPT), run_name="__main__") + assert error.value.code == 1