diff --git a/.github/workflows/code-mower-gate.yml b/.github/workflows/code-mower-gate.yml index 7ea431c6..780d11c3 100644 --- a/.github/workflows/code-mower-gate.yml +++ b/.github/workflows/code-mower-gate.yml @@ -286,10 +286,13 @@ jobs: attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) except ImportError: # pragma: no cover - unit-test/package fallback from code_mower.context_review import required_for_checkout @@ -298,12 +301,17 @@ jobs: attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) + LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" + # github_actions_comment_attested verifies the hidden CODE_MOWER_AUDIT_RUN marker # and requires comment_id/body_sha256 to match the issue comment being evaluated. def emit(state, description): @@ -342,7 +350,26 @@ jobs: if isinstance(item, dict) } lanes = [lane for lane in lanes if isinstance(lane, dict)] - comments = flatten_paginated_items(comments_payload) + # Comment pages are validated under the shared record contract + # *before* anything flattens, filters or stringifies them. The + # generic flattener drops members it cannot use -- right for a mixed + # timeline, wrong for a comment history, where a dropped comment is a + # dropped marker -- and the `str(... or "")` below would turn a + # present non-string body into plausible text. A history nobody could + # read would otherwise reach the gate looking ordinary. Timeline + # events keep the generic flattener: they are not comments and are + # never read for lineage. + comment_history_readable = True + comment_history_problem = "" + try: + comments = flatten_paginated_comments(comments_payload) + except Exception as exc: + comment_history_readable = False + comment_history_problem = str(exc) + # The gate is already failing on this. Salvaging the readable + # members would hand records nobody could validate to every other + # consumer below, so nothing downstream sees this history at all. + comments = [] events = flatten_paginated_items(events_payload) audit_runs = audit_runs_payload if isinstance(audit_runs_payload, list) else [] configured_decision_authorities = ( @@ -432,16 +459,43 @@ jobs: context_required=context_required, ) - builder_matches = [] - if isinstance(exclusion, dict) and exclusion.get("enabled"): - label_map = mapping("labels") - for label in labels: - if label in label_map: - builder_matches.append(str(label_map[label])) - author_map = {str(k).lower(): str(v) for k, v in mapping("authors").items()} - if pr_author.lower() in author_map: - builder_matches.append(author_map[pr_author.lower()]) - builder_matches = list(dict.fromkeys(builder_matches)) + # Contribution lineage, not one label and one author. The hidden + # marker is a transport and never an authorization, so episodes are + # read only from the repository's configured decision authorities -- + # the same trust contract the publisher, both labelers and every + # reviewer wrapper apply. Permission to post an audit verdict is not + # takeover authority, so reviewer bot authors are deliberately not + # consulted here. An unconfigured checkout trusts nobody, reads no + # episodes, and falls back to the ordinary single-builder answer. + lineage_authorities = { + item.strip().lower().lstrip("@") + for item in decision_authorities + if item.strip() + } + lineage_episodes = [] + lineage_readable = comment_history_readable + for comment in comments: + comment_body = str(comment.get("body") or "") + if LINEAGE_MARKER not in comment_body: + continue + comment_author = str(((comment.get("user") or {}).get("login")) or "") + if comment_author.strip().lower().lstrip("@") not in lineage_authorities: + continue + try: + lineage_episodes.extend(episodes_from_comment_body(comment_body)) + except Exception: + lineage_readable = False + builder_lineage = resolve_builder_lineage( + labels=sorted(labels), + author=pr_author, + config=exclusion if isinstance(exclusion, dict) else {"enabled": False}, + repo=os.environ.get("GITHUB_REPOSITORY", ""), + pr_number=pr_number, + branch=str(((pr_payload.get("head") or {}).get("ref")) or ""), + head_sha=head_sha, + episodes=lineage_episodes, + ) + builder_matches = list(builder_lineage.contributors) def lane_display(lane): return str(lane.get("display_name") or lane.get("id") or lane.get("done") or "") @@ -531,8 +585,16 @@ jobs: ) ) - if len(builder_matches) > 1: - emit("failure", "conflicting Code Mower builder identity") + if not lineage_readable: + emit( + "failure", + "Code Mower builder contribution evidence is unreadable" + + (": " + comment_history_problem if comment_history_problem else ""), + ) + elif builder_lineage.status == "conflict": + emit("failure", "conflicting Code Mower builder identity: " + builder_lineage.owner_action) + elif builder_lineage.status == "waiting": + emit("pending", "waiting for builder lineage: " + builder_lineage.owner_action) elif owner_label in labels: emit("pending", owner_label + ": waiting on owner") elif owner_sitting_label and owner_sitting_label in labels: @@ -548,11 +610,13 @@ jobs: elif blocked: emit("failure", "blocked audit: " + ", ".join(blocked)) else: - excluded_builder = builder_matches[0] if builder_matches else "" + # Every verified contributor to this head is excluded, not just + # the one the active label happens to name. + excluded_builders = set(builder_matches) required = [ lane for lane in lanes - if str(lane.get("author_lane") or lane.get("id") or "") != excluded_builder + if str(lane.get("author_lane") or lane.get("id") or "") not in excluded_builders ] in_flight = in_flight_lanes(required) missing = [ @@ -562,7 +626,7 @@ jobs: or latest_current_verdict(lane) != "done" ] if not required: - if excluded_builder: + if excluded_builders: emit("failure", "no independent Code Mower audit lane remains") else: emit("failure", "no Code Mower merge-authority lanes configured") diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json index 9ab45d8d..0b3d314c 100644 --- a/code-mower-package-manifest.json +++ b/code-mower-package-manifest.json @@ -372,6 +372,11 @@ "source": "src/code_mower/builder_experiment.py", "target": "src/code_mower/builder_experiment.py" }, + { + "kind": "core", + "source": "tools/builder_lineage.py", + "target": "src/code_mower/builder_lineage.py" + }, { "kind": "core", "source": "src/code_mower/builder_runs.py", @@ -1372,6 +1377,11 @@ "source": "src/code_mower/provider_runners/github_pr.py", "target": "src/code_mower/provider_runners/github_pr.py" }, + { + "kind": "reviewer", + "source": "src/code_mower/provider_runners/lineage.py", + "target": "src/code_mower/provider_runners/lineage.py" + }, { "kind": "reviewer", "source": "src/code_mower/provider_runners/pr_worktree.py", diff --git a/src/code_mower/audit_labeler_lib.py b/src/code_mower/audit_labeler_lib.py index 797a21f9..30a0427e 100644 --- a/src/code_mower/audit_labeler_lib.py +++ b/src/code_mower/audit_labeler_lib.py @@ -26,12 +26,45 @@ try: from . import decisions as code_mower_decisions from . import context_review as code_mower_context_review + from .builder_lineage import ( + LINEAGE_MARKER, + Lineage, + LineageError, + episodes_from_comment_body, + branch_lane_from_identity, + lanes_from_identity, + require_comment_list, + resolve_identity_only, + resolve_lineage, + ) except ImportError: # pragma: no cover - copied tools fallback import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore + from builder_lineage import ( # type: ignore + LINEAGE_MARKER, + Lineage, + LineageError, + episodes_from_comment_body, + branch_lane_from_identity, + lanes_from_identity, + require_comment_list, + resolve_identity_only, + resolve_lineage, + ) else: # pragma: no cover - direct helper execution import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore + from builder_lineage import ( # type: ignore + LINEAGE_MARKER, + Lineage, + LineageError, + episodes_from_comment_body, + branch_lane_from_identity, + lanes_from_identity, + require_comment_list, + resolve_identity_only, + resolve_lineage, + ) MIN_ABBREVIATED_SHA_LENGTH = 7 AUTHOR_EXCLUSION_ENV = "CODE_MOWER_AUTHOR_EXCLUSION_JSON" @@ -196,32 +229,201 @@ def load_author_exclusion_config(raw: str | None = None) -> Mapping[str, Any]: return parsed if isinstance(parsed, Mapping) else {"enabled": False} +def resolve_builder_lineage( + *, + labels: Sequence[str], + author: str, + config: Mapping[str, Any], + repo: str = "", + pr_number: Any = 0, + branch: str = "", + head_sha: str = "", + episodes: Sequence[Any] = (), +) -> Lineage: + """Resolve the shared builder lineage from labeler-side evidence. + + Labelers see labels and an author for certain, and may or may not have the + exact head plus published contribution episodes to hand. Without exact-head + evidence there is no takeover to resolve, so the result is the ordinary + single-builder decision; with it, the same resolver the gate, the runner + and the reviewer wrappers use answers the question. + """ + + opener_lane, label_lanes = lanes_from_identity( + identity=config, labels=labels, author=author + ) + # The configured branch identity is a signal the deployment asked to be + # counted; rendering it and then resolving without it is how a `codex/` + # branch labelled `builder:claude` resolved to a sole Claude writer and + # admitted Codex to review its own diff. + branch_lane = branch_lane_from_identity(identity=config, branch=branch) + if not (head_sha and repo and pr_number): + return resolve_identity_only( + opener_lane=opener_lane, + label_lanes=label_lanes, + branch_lane=branch_lane, + ) + return resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + branch_lane=branch_lane, + ) + + +@dataclass(frozen=True) +class LineageContext: + """The trusted exact-head evidence a labeler carries into resolution. + + Every field has to come from something the labeler verified for itself: the + repository it is running in, the head it fetched from the pull request, and + episodes published by an author it already trusts. An empty context is not + a failure -- it is the honest statement that this call has no exact-head + evidence, and resolution falls back to the ordinary single-builder answer. + """ + + repo: str = "" + pr_number: Any = 0 + branch: str = "" + head_sha: str = "" + episodes: tuple[Any, ...] = () + + +#: A labeler that has no exact-head evidence at all. +NO_LINEAGE = LineageContext() + + +def published_lineage_episodes( + comments: Sequence[Mapping[str, Any]] | None, + *, + trusted_author: Callable[[str], bool], +) -> tuple[Any, ...]: + """Contribution episodes published by comment authors the caller trusts. + + The hidden marker is a transport for bounded metadata, never an + authorization: trust is decided entirely by ``trusted_author``, so an + arbitrary commenter cannot assert a takeover into existence. Unreadable + published evidence raises :class:`LineageError` so the caller fails closed + rather than labelling from a partially parsed history. + """ + + collected: list[Any] = [] + for comment in comments or (): + if not isinstance(comment, Mapping): + continue + login = str(((comment.get("user") or {}).get("login")) or "") + body = str(comment.get("body") or "") + if not login or LINEAGE_MARKER not in body or not trusted_author(login): + continue + collected.extend(episodes_from_comment_body(body)) + return tuple(collected) + + +def lineage_decision_authorities() -> tuple[str, ...]: + """The repository's configured decision authorities, for marker trust.""" + + return code_mower_decisions.decision_authorities_from_env() + + +def lineage_marker_author_trust( + *, + authorities: Sequence[str] = (), +) -> Callable[[str], bool]: + """Who this labeler may read published lineage markers from. + + Trust is exactly the repository's configured decision authorities -- the + accounts it already treats as authoritative about its own state. Nothing + else is accepted, and in particular an audit bot able to post a verdict is + not thereby able to assert a takeover. An unconfigured checkout trusts + nobody, reads no episodes, and behaves exactly as it does today. + """ + + allowed = { + str(item).strip().lower().lstrip("@") + for item in authorities + if str(item).strip() + } + + def trusted(login: str) -> bool: + return bool(allowed) and str(login).strip().lower().lstrip("@") in allowed + + return trusted + + +def lineage_context( + *, + repo: str, + pr_number: Any, + branch: str = "", + head_sha: str | None = "", + comments: Sequence[Mapping[str, Any]] | None = (), + trusted_author: Callable[[str], bool] | None = None, +) -> LineageContext: + """Assemble exact-head lineage evidence for a labeler entry path. + + Returns :data:`NO_LINEAGE` when the head could not be verified, because + lineage resolved against an unverified head would decide the wrong diff. + Unreadable published evidence is propagated as a + :class:`LineageError` for the caller's fail-closed handling. + """ + + head = str(head_sha or "") + if not head or not repo or not pr_number: + return NO_LINEAGE + episodes: tuple[Any, ...] = () + if trusted_author is not None: + episodes = published_lineage_episodes(comments, trusted_author=trusted_author) + return LineageContext( + repo=str(repo), + pr_number=pr_number, + branch=str(branch or ""), + head_sha=head, + episodes=episodes, + ) + + def builder_identity_matches( *, labels: Sequence[str], author: str, text: str, config: Mapping[str, Any], + lineage: LineageContext | None = None, ) -> tuple[str, ...]: - if not bool(config.get("enabled")): - return () - label_map = _string_mapping(config.get("labels")) - author_map = { - key.lower(): value - for key, value in _string_mapping(config.get("authors")).items() - } - matches: list[str] = [] + """Ordered verified builder lanes for this pull request. - for label in labels: - lane = label_map.get(str(label)) - if lane: - matches.append(lane) + ``text`` is retained for call compatibility and is deliberately unused: + freeform prose is not contribution evidence. + """ - lane = author_map.get(author.lower()) - if lane: - matches.append(lane) - - return tuple(dict.fromkeys(matches)) + if not bool(config.get("enabled")): + return () + evidence = lineage or NO_LINEAGE + resolved = resolve_builder_lineage( + labels=labels, + author=author, + config=config, + repo=evidence.repo, + pr_number=evidence.pr_number, + branch=evidence.branch, + head_sha=evidence.head_sha, + episodes=evidence.episodes, + ) + if resolved.status != "resolved": + # Preserve the historical "more than one identity" shape so a caller + # inspecting matches can still tell a conflict from a clean match. + opener_lane, label_lanes = lanes_from_identity( + identity=config, labels=labels, author=author + ) + candidates = tuple( + dict.fromkeys(list(label_lanes) + ([opener_lane] if opener_lane else [])) + ) + return candidates if len(candidates) > 1 else resolved.contributors + return resolved.contributors def author_exclusion_reason( @@ -231,20 +433,40 @@ def author_exclusion_reason( author: str, text: str, config: Mapping[str, Any] | None = None, + lineage: LineageContext | None = None, ) -> str | None: + """Why ``lane_name`` may not label its own work, or ``None``. + + Exclusion follows verified contribution, not authorship: every contributing + lane is excluded, and a lane that merely opened the PR before handing it + over is excluded too because its commits are still in the diff. Conversely, + with exact-head evidence in ``lineage``, a lane that never touched this + head is *not* excluded even when a reconciled label names a different lane + than the opener -- which is the whole point of carrying the evidence here. + """ + exclusion_config = config or load_author_exclusion_config() - matches = builder_identity_matches( - labels=labels, - author=author, - text=text, - config=exclusion_config, - ) - if not matches: + if not bool(exclusion_config.get("enabled")): return None - if len(set(matches)) > 1: + evidence = lineage or NO_LINEAGE + try: + resolved = resolve_builder_lineage( + labels=labels, + author=author, + config=exclusion_config, + repo=evidence.repo, + pr_number=evidence.pr_number, + branch=evidence.branch, + head_sha=evidence.head_sha, + episodes=evidence.episodes, + ) + except LineageError: + return "builder contribution evidence is unreadable; skipping author-excluded label update" + if resolved.status == "conflict": return "conflicting builder identity; skipping author-excluded label update" - builder_lane = matches[0] - if builder_lane == lane_name: + if resolved.status == "waiting": + return "builder contribution lineage is behind the current head; skipping author-excluded label update" + if resolved.contributed(lane_name): return f"{lane_name} lane excluded for builder-authored PR" return None @@ -400,6 +622,42 @@ def flatten_paginated_items(payload: Any) -> list[dict[str, Any]]: return items +def flatten_paginated_comments(payload: Any) -> list[dict[str, Any]]: + """Flatten a paginated *comment* response under the shared record contract. + + :func:`flatten_paginated_items` is for timeline entries and stays as it is: + it drops members it cannot use, which is right for a mixed event stream and + wrong for a comment history. A dropped comment is a dropped marker, and the + marker proving a takeover is in the newest part of the history. So comment + pages are validated here -- before anything flattens, filters or + stringifies them -- and an unreadable page raises rather than shrinking. + + Genuinely empty pages, ``user: null`` and an omitted optional ``body`` are + GitHub's own schema and stay ordinary. + """ + + if not isinstance(payload, list): + raise LineageError("the comment history did not come back as a paginated array") + comments: list[dict[str, Any]] = [] + for index, page in enumerate(payload, start=1): + # Every page is an array. The gate fetches `gh api --paginate --slurp`, + # whose shape is a list of pages, so anything else in that position is + # not a page. Accepting a bare object as a one-comment page -- which is + # what the generic flattener does -- reinterprets `{}` or + # `{"comments": []}` as a comment with no body and no author, and a + # response nobody could read then looks like an absent history: the + # gate waits for an audit instead of refusing. Record validation cannot + # recover that, because by then the wrapper has already made the + # payload look well formed. + if not isinstance(page, list): + raise LineageError(f"comment page {index} is not an array of comments") + comments.extend( + dict(comment) + for comment in require_comment_list(page, what=f"comment page {index}") + ) + return comments + + def audit_comment_head_sha(body: str) -> str: match = HEAD_SHA_LINE_RE.search(body) return match.group(1) if match else "" @@ -936,13 +1194,23 @@ def fetch_issue_comments( "GET", f"/repos/{repo}/issues/{issue_number}/comments?per_page=100&page={page}", tokens=tokens, - ) or [] - if not isinstance(chunk, list): - raise RuntimeError("GitHub API issue comments returned a non-list response") - if not chunk: + ) + # Shape and the fields lineage reads are settled before anything else + # looks at the page. `or []` made `None`, `False` and `{}` -- every + # falsey successful response -- end the read, and filtering by + # `isinstance` dropped malformed records silently. Both report "there + # is nothing here" for "this could not be read", and this page feeds + # the trailer labeler's own label decision. + try: + page_comments = require_comment_list( + chunk, what=f"issue comments page {page} for {repo}#{issue_number}" + ) + except LineageError as exc: + raise RuntimeError(str(exc)) from None + if not page_comments: return comments - comments.extend(comment for comment in chunk if isinstance(comment, dict)) - if len(chunk) < 100: + comments.extend(dict(comment) for comment in page_comments) + if len(page_comments) < 100: return comments page += 1 raise IssueCommentPaginationLimitExceeded( diff --git a/src/code_mower/board.py b/src/code_mower/board.py index a9ad007d..e47cad70 100644 --- a/src/code_mower/board.py +++ b/src/code_mower/board.py @@ -729,6 +729,19 @@ def _safe_bool(value: object) -> bool: return bool(value) +def _safe_lane_names(value: object, *, limit: int = 8) -> list[str]: + """Bound a lineage contributor list to short lane names.""" + + if not isinstance(value, list): + return [] + names = [] + for item in value[:limit]: + name = _safe_text(item, limit=40) + if name and name not in names: + names.append(name) + return names + + def _safe_reviewer_outcomes(value: object) -> list[dict[str, Any]]: if not isinstance(value, list): return [] @@ -764,6 +777,11 @@ def _supervised_decision_payload(decision: Mapping[str, Any]) -> dict[str, Any]: "promoted_reviewers_passed": _safe_bool(decision.get("promoted_reviewers_passed")), "would_mutate": _safe_bool(decision.get("would_mutate")), "reviewer_outcomes": _safe_reviewer_outcomes(decision.get("reviewer_outcomes")), + # Lane names only. The contributor list is what makes an exclusion + # legible on the Board; nothing about the diff, the source or the + # private handoff binding crosses this boundary. + "builder_lineage_status": _safe_text(decision.get("builder_lineage_status"), limit=40), + "builder_contributors": _safe_lane_names(decision.get("builder_contributors")), } if pr_number := _int(decision.get("pr_number")): payload["pr_number"] = pr_number diff --git a/src/code_mower/builder_lineage.py b/src/code_mower/builder_lineage.py new file mode 100644 index 00000000..2b28fe12 --- /dev/null +++ b/src/code_mower/builder_lineage.py @@ -0,0 +1,1135 @@ +"""Exact-head builder contribution lineage after a verified handoff. + +A pull request can be built by more than one Code Mower builder lane. The +opener, the branch prefix and the single active ``builder:*`` label each +describe at most one of those lanes, so any of them alone will misdescribe a +PR that changed hands. This module keeps the ordered contribution history +instead, and derives the one current writer from it. + +Trust rules this module exists to enforce: + +* A contribution episode is evidence produced by the verified handoff and + delivery path (see :mod:`code_mower.lane_handoff`), which observed the source + writer going quiescent and observed both heads. A caller-supplied boolean, a + PR body marker, a commit trailer, the PR opener or the most recent label are + none of them able to attest that a takeover happened. +* Episodes are bound to repository, pull request, branch, source lane, + destination lane, expected head and resulting head. An episode that does not + bind to the pull request under decision is not evidence about it. +* Resolution is exact-head. Lineage that stops short of the current head is + *waiting*, never a guess about who wrote the current diff. +* Conflicting, duplicated, unchained or unbound evidence fails closed with one + concise owner action rather than picking a winner. + +Everything here is metadata-only: lane names, a repository slug, a PR number, a +branch name and commit shas. No source, diffs, prompts, transcripts, paths, +provider references or credentials pass through this module, so a resolved +lineage is safe for the existing public/cloud metadata contract. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping, Sequence + + +SCHEMA = "code_mower.builderLineage.v1" +EPISODE_SCHEMA = "code_mower.contributionEpisode.v1" +RECORD_SCHEMA = "code_mower.builderLineageRecord.v1" + +#: Hidden marker used to publish bounded lineage metadata on a pull request. +#: Only comments from an already trusted author are parsed; the marker is a +#: transport, never an authorization. +LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" +LINEAGE_MARKER_RE = re.compile( + r"", + re.DOTALL, +) + +#: Marker *presence*, decided without looking at the payload at all. +#: +#: :data:`LINEAGE_MARKER_RE` only matches a complete, object-shaped, properly +#: terminated marker. Looking for published evidence with it alone means an +#: unterminated or non-object marker is not read as broken -- it is not seen, +#: and a trusted comment that announces lineage reports none. Absence and +#: unreadability are opposite answers: one admits an independent reviewer on +#: the ordinary single-builder story, the other must stop. Presence is found +#: first, and the payload is then required to parse. +LINEAGE_MARKER_PRESENT_RE = re.compile( + r"" + + +def _reject_duplicate_keys(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + """``object_pairs_hook`` that refuses an object naming a key twice. + + ``json.loads`` keeps the last value for a repeated key, so one marker can + carry two answers to the same question -- two ``episodes`` lists, two + ``schema`` values, two ``resulting_head`` shas inside one episode -- and + every reader silently agrees on whichever came last. Which one describes + the diff is exactly what must not be decided by parser order. This applies + at every depth, so a conflicting nested binding is refused too. + """ + + seen: dict[str, Any] = {} + for key, value in pairs: + if key in seen: + raise ValueError(f"duplicate key in published builder lineage: {key}") + seen[key] = value + return seen + + +def _loads_without_duplicate_keys(payload: str) -> Any: + return json.loads(payload, object_pairs_hook=_reject_duplicate_keys) + + +def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: + """Parse lineage markers out of one already trusted comment body. + + The caller decides trust. This function never treats the presence of a + marker as evidence that its author was allowed to publish one. + """ + + text = _text(body) + present = LINEAGE_MARKER_PRESENT_RE.findall(text) + if not present: + # An ordinary comment. Not evidence, and not a failure either. + return () + if len(present) > 1: + # Two markers on one comment cannot both be "the" published lineage, + # and which one describes the head is exactly what may not be guessed. + raise LineageError("published builder lineage is ambiguous") + matches = LINEAGE_MARKER_RE.findall(text[:MAX_MARKER_BODY_CHARS]) + if len(matches) != 1: + # The marker is there, but no single complete object payload parses out + # of it: unterminated, not an object, or cut off past the bound. + raise LineageError("published builder lineage is unreadable") + try: + payload = _loads_without_duplicate_keys(matches[0]) + except (ValueError, RecursionError): + raise LineageError("published builder lineage is unreadable") from None + if not isinstance(payload, Mapping) or payload.get("schema") != SCHEMA: + raise LineageError("published builder lineage schema is unsupported") + items = payload.get("episodes") + if not isinstance(items, list) or len(items) > MAX_EPISODES: + raise LineageError("published builder lineage is unreadable") + if not items: + # A marker announces lineage. The publisher refuses to publish zero + # episodes, so a trusted marker carrying an empty chain is not a + # history that happens to be empty -- it is a claim that contradicts + # itself, and reading it as ordinary absence is how announced evidence + # disappears into the single-builder answer. + raise LineageError("published builder lineage declares no episodes") + return tuple(episode_from_mapping(item) for item in items) diff --git a/src/code_mower/builder_runs.py b/src/code_mower/builder_runs.py index 0354a01b..d090b142 100644 --- a/src/code_mower/builder_runs.py +++ b/src/code_mower/builder_runs.py @@ -8,13 +8,23 @@ import re import sys import uuid -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from pathlib import Path -from typing import Any, Mapping +from typing import Any, Mapping, Sequence from urllib.parse import urlparse from code_mower import __version__ +from code_mower.builder_lineage import ( + Lineage, + resolve_identity_only, + resolve_lineage, +) +from code_mower.audit_labeler_lib import ( + lineage_marker_author_trust, + published_lineage_episodes, +) +from code_mower.decisions import decision_authorities_from_env from code_mower.work_orders import parse_github_issue_ref, parse_github_pr_ref @@ -56,6 +66,37 @@ ) +#: Provider/executor identities map onto the lane names lineage speaks in. +#: Inference names a candidate lane; it never names a contribution. +_INFERENCE_LANES = { + "codex": "codex", + "claude": "claude", + "cursor_cloud_agent": "cursor", + "devin": "devin", + "devin_cli": "devin", +} +_LABEL_LANES = { + "builder:codex": "codex", + "builder:claude": "claude", + "builder:cursor": "cursor", + "builder:devin": "devin", +} + + +#: The provider/executor a lane writes under, for attributing a run to the lane +#: that actually wrote the diff rather than to whoever opened the pull request. +_LANE_ATTRIBUTION = { + "codex": ("codex", "chatgpt-codex-connector"), + "claude": ("claude", "claude_code_action"), + "cursor": ("cursor_cloud_agent", "cursor_cloud_agent"), + "devin": ("devin", "devin"), +} + + +def _lane_from_inference(inference: "BuilderInference | None") -> str: + return "" if inference is None else _INFERENCE_LANES.get(inference.provider, "") + + @dataclass(frozen=True) class PullRequestMetadata: repo: str @@ -64,6 +105,13 @@ class PullRequestMetadata: author: str branch: str body: str + #: Exact head, active labels and pull request comments as they appear in + #: the authenticated payload auto-record is handed. Auto-record used to + #: read only the opener and the branch prefix out of that payload, which is + #: why it kept attributing a taken-over pull request to whoever opened it. + head_sha: str = "" + labels: tuple[str, ...] = () + comments: tuple[Mapping[str, Any], ...] = () @dataclass(frozen=True) @@ -123,6 +171,16 @@ def _load_json_object(path: Path) -> dict[str, Any]: return payload +def _load_json_list(path: Path) -> list[Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"could not read JSON array {path}: {exc}") from exc + if not isinstance(payload, list): + raise ValueError(f"{path} must contain a JSON array") + return payload + + def _nested_text(payload: Mapping[str, Any], *path: str) -> str: current: Any = payload for key in path: @@ -163,8 +221,18 @@ def _branch_from_pr_payload(pr: Mapping[str, Any]) -> str: ) -def load_pull_request_metadata(path: Path, *, repo: str = "") -> PullRequestMetadata: +def load_pull_request_metadata( + path: Path, *, repo: str = "", comments_path: Path | None = None +) -> PullRequestMetadata: payload = _load_json_object(path) + # A GitHub event payload never carries the pull request's comments, so the + # published lineage arrives as its own authenticated fetch. It is kept + # apart from the payload rather than merged into it: REST states the + # *number* of comments under the same key, and a merged list could no + # longer be told from the metadata it sits beside. + explicit_comments = ( + _load_json_list(comments_path) if comments_path is not None else None + ) pr = _record(payload.get("pull_request")) or payload number = _text(pr.get("number")) or _text(payload.get("number")) url = _text(pr.get("html_url")) or _text(pr.get("url")) @@ -175,9 +243,100 @@ def load_pull_request_metadata(path: Path, *, repo: str = "") -> PullRequestMeta author=_author_from_pr_payload(pr), branch=_branch_from_pr_payload(pr), body=_text(pr.get("body")), + head_sha=_text((_record(pr.get("head")) or {}).get("sha")), + labels=_labels_from_pr_payload(pr), + comments=_comments_from_pr_payload(payload, pr, explicit=explicit_comments), ) +def _labels_from_pr_payload(pr: Mapping[str, Any]) -> tuple[str, ...]: + names: list[str] = [] + for label in pr.get("labels") or (): + if isinstance(label, Mapping): + name = _text(label.get("name")) + else: + name = _text(label) + if name: + names.append(name) + return tuple(names) + + +#: "This payload carries no comment history at all", which is not the same +#: answer as "it carries one that cannot be read". +_NO_HISTORY = object() + + +def _selected_embedded_history( + payload: Mapping[str, Any], pr: Mapping[str, Any] +) -> tuple[Any, str]: + """The embedded comment history, told apart from REST's comment *count*. + + ``gh ... --json comments`` nests the history under the pull request. REST + puts an integer under the same name -- how many comments there are, with + the history fetched separately -- so a count is metadata, not a malformed + history, and must not shadow the fetched list or fail the run. Anything + else present under that name is a selected history and has to be readable: + null, false, an object and a nested list are all unreadable, not absent. + """ + + for container, source in ( + (pr, "the pull request's embedded comment history"), + (payload, "the event payload's embedded comment history"), + ): + if "comments" not in container: + continue + value = container["comments"] + if isinstance(value, int) and not isinstance(value, bool): + continue + return value, source + return _NO_HISTORY, "" + + +def _comments_from_pr_payload( + payload: Mapping[str, Any], + pr: Mapping[str, Any], + *, + explicit: Any = None, +) -> tuple[Mapping[str, Any], ...]: + """Pull request comments from whichever transport supplied the payload. + + ``gh pr view --json comments`` names the commenter ``author`` and nests the + list under the pull request; the REST event payload names it ``user`` and a + caller may hand the list in alongside. Both are normalised to the one shape + every lineage reader consumes, so auto-record applies the same marker-trust + rule as the gate rather than a transport-specific one. + + The source actually used is selected explicitly, then validated *before* + it is normalised. This is the direct CLI's own input boundary: normalising + first skipped entries that were not objects and ran `_text()` over whatever + was there, so a list-valued body or an object login became a + plausible-looking record, and the already-clean tuple handed on afterwards + had nothing left to detect. An unreadable comments source is refused here, + before any attribution exists -- and a REST comment *count* is not one. + """ + + from .builder_lineage import require_comment_list + + if explicit is not None: + # The caller fetched the history itself and said so. That is the + # selected source, and no field beside it may shadow it. + raw, source = explicit, "the supplied comment history" + else: + raw, source = _selected_embedded_history(payload, pr) + if raw is _NO_HISTORY: + # No history field at all. Ordinary: the run is attributed from + # the pull request's own metadata, as it always was. + return () + validated = require_comment_list(raw, what=source) + normalised: list[Mapping[str, Any]] = [] + for item in validated: + user = _record(item.get("user")) or _record(item.get("author")) or {} + normalised.append( + {"user": {"login": _text(user.get("login"))}, "body": _text(item.get("body"))} + ) + return tuple(normalised) + + def _cursor_agent_url(body: str) -> str: match = CURSOR_AGENT_URL_RE.search(body) return match.group(0).rstrip(".,") if match else "" @@ -242,16 +401,87 @@ def infer_builder_from_pr(metadata: PullRequestMetadata) -> BuilderInference | N ) +def resolve_builder_lineage( + metadata: PullRequestMetadata, + *, + head_sha: str = "", + episodes: Sequence[Any] = (), + labels: Sequence[str] = (), +) -> Lineage: + """Resolve the same exact-head lineage the gate and reviewers resolve. + + Auto-record used to describe a pull request from its author, its branch + prefix and provider prose. Those still bootstrap the lane name, but they + cannot describe a handoff, so verified contribution episodes decide the + contributor list and the current writer whenever they exist. + """ + + lane = _lane_from_inference(infer_builder_from_pr(metadata)) + label_lanes = tuple( + lane_name + for lane_name in ( + _LABEL_LANES.get(str(label).strip().lower()) for label in labels + ) + if lane_name + ) + if not (head_sha and metadata.repo and metadata.number): + return resolve_identity_only(opener_lane=lane, label_lanes=label_lanes) + return resolve_lineage( + repo=metadata.repo, + pr_number=metadata.number, + branch=metadata.branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=lane, + label_lanes=label_lanes, + ) + + def build_auto_builder_run_event( metadata: PullRequestMetadata, *, created_at: str = "", lens: str = "implementation", status: str = "pr-opened", + head_sha: str = "", + episodes: Sequence[Any] = (), + labels: Sequence[str] = (), ) -> tuple[dict[str, Any] | None, BuilderInference | None]: inference = infer_builder_from_pr(metadata) if inference is None: return None, None + lineage = resolve_builder_lineage( + metadata, head_sha=head_sha, episodes=episodes, labels=labels + ) + # Attribution follows the verified current writer, not the opener. A pull + # request Devin opened and Codex took over is a Codex run; recording it + # against Devin is the same mistake the label and the branch prefix make. + # The builder id and run url stay with the inference that produced them -- + # they describe the opener's run and would be a fabrication on any other. + # + # Only a verified *writer transition* may move attribution. Lanes are + # coarser than the transports inside them: an ordinary `devin/` branch + # infers the local `devin_cli` transport but normalizes to lane `devin`, + # whose canonical attribution is the hosted pair. Comparing transports + # against that pair rewrote every ordinary local Devin CLI run into a + # hosted one, cleared its builder id and claimed high confidence -- on + # identity-only resolution that had observed no episode at all. So the + # comparison is between lanes, and it is gated on evidence: no episodes + # means no transition, and a same-lane continuation keeps the transport + # its own inference established. + inferred_lane = _lane_from_inference(inference) + writer_lane = lineage.current_writer if lineage.resolved else "" + writer = _LANE_ATTRIBUTION.get(writer_lane) if writer_lane else None + if writer and lineage.episodes and writer_lane != inferred_lane: + inference = replace( + inference, + provider=writer[0], + executor=writer[1], + builder_id="", + run_url="", + confidence="high", + signals=inference.signals + (f"builder_lineage:{writer_lane}",), + ) pr_ref = metadata.url or ( f"{metadata.repo}#{metadata.number}" if metadata.repo and metadata.number else "" ) @@ -274,6 +504,12 @@ def build_auto_builder_run_event( event["dimensions"]["builder_inference_confidence"] = inference.confidence event["dimensions"]["builder_inference_signals"] = list(inference.signals) event["dimensions"]["pr_author"] = metadata.author + # Bounded metadata only: lane names, a status and a head prefix. The full + # lineage record stays private; what a recorded run needs to say publicly + # is who contributed, who is writing now, and whether that is settled. + event["dimensions"]["builder_lineage_status"] = lineage.status + event["dimensions"]["builder_contributors"] = list(lineage.contributors) + event["dimensions"]["builder_current_writer"] = lineage.current_writer return event, inference @@ -625,6 +861,14 @@ def main(argv: list[str] | None = None) -> int: help="GitHub pull_request event JSON or `gh pr view --json ...` output.", ) auto_record.add_argument("--repo", default="", help="owner/repo fallback for PR metadata.") + auto_record.add_argument( + "--comments-json", + type=Path, + help=( + "Authenticated pull request comments, for published builder lineage. " + "Markers are read only from configured decision authorities." + ), + ) auto_record.add_argument("--status", default="pr-opened") auto_record.add_argument("--lens", default="implementation") auto_record.add_argument("--created-at", default="") @@ -692,12 +936,29 @@ def main(argv: list[str] | None = None) -> int: print(f"Issue: {event['dimensions']['issue_url']}") return 0 if args.command == "auto-record": - metadata = load_pull_request_metadata(args.pr_json, repo=args.repo) + metadata = load_pull_request_metadata( + args.pr_json, repo=args.repo, comments_path=args.comments_json + ) + # The authenticated payload already carries the exact head, the + # active labels and the pull request's comments. Auto-record used + # to read only the opener out of it, so a taken-over pull request + # recorded a run against the lane that opened it. The published + # lineage is read under the gate's own trust rule: a marker from a + # configured decision authority, and nothing else. + episodes = published_lineage_episodes( + metadata.comments, + trusted_author=lineage_marker_author_trust( + authorities=decision_authorities_from_env() + ), + ) event, inference = build_auto_builder_run_event( metadata, created_at=args.created_at, lens=args.lens, status=args.status, + head_sha=metadata.head_sha, + labels=metadata.labels, + episodes=episodes, ) if event is None or inference is None: payload = { diff --git a/src/code_mower/claude_audit_pr.py b/src/code_mower/claude_audit_pr.py index db497c52..02420248 100644 --- a/src/code_mower/claude_audit_pr.py +++ b/src/code_mower/claude_audit_pr.py @@ -1357,6 +1357,49 @@ def format_comment( return limit_comment_body(body, trailer, provider_name="Claude") +def _require_independent_review( + lane, repo, pr_number, pr_meta, head_sha, *, authorities=(), fetch_comments=None +): + """Admit ``lane`` against verified contribution lineage, or raise. + + Evidence is both the host's configured private store and the bounded + lineage published on the pull request by a configured decision authority. + A reviewer host that recorded nothing has an empty store, so reading only + that would answer "no takeover happened" on precisely the independent hosts + where a takeover is what admission turns on. + + Imported lazily so the direct-script execution fallback this module + supports does not have to resolve the package layout at import time. + """ + + try: + from code_mower.builder_lineage import LineageError + from code_mower.provider_runners.lineage import ( + identity_with_lane_floor, load_identity, require_reviewer_lane, reviewer_evidence, + ) + except ImportError: # pragma: no cover - direct script execution fallback + from builder_lineage import LineageError # type: ignore + from provider_runners.lineage import ( # type: ignore + identity_with_lane_floor, load_identity, require_reviewer_lane, reviewer_evidence, + ) + # Real recorded evidence, not the resolver's empty default: an admission + # decided on no episodes cannot see a takeover, which is the whole point. + try: + episodes = reviewer_evidence( + repo, pr_number, authorities=authorities, fetch_comments=fetch_comments + ) + except LineageError as exc: + raise RuntimeError( + f"{lane} reviewer lane is not admitted for {repo}#{pr_number} at " + f"{str(head_sha)[:12]}: lineage_unreadable; {exc}" + ) from None + return require_reviewer_lane( + lane, repo, pr_number, pr_meta, head_sha, + episodes=episodes, + identity=identity_with_lane_floor(load_identity(), lane), + ) + + def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAuditResult: audit_started = time.monotonic() local_repo = config.repo_paths.get(repo) @@ -1380,6 +1423,12 @@ def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAudi "refusing Claude self-audit for claude/* branch. " "Use --allow-claude-owned only for explicitly informational dogfood." ) + # The branch-prefix check above only sees where the PR started. Contribution + # lineage at the exact head sees who actually wrote the diff, including a + # Claude takeover of another lane's branch -- but deciding that needs the + # repository's configured decision authorities, which are read from the + # immutable base #955 pins below. The admission itself therefore runs after + # that pin and before any provider execution. config.progress.emit( "audit", @@ -1534,6 +1583,20 @@ def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAudi config.decision_authorities, trusted_ref=config.base_ref, ) + # Reviewer independence, decided on the authorities the pinned base names + # and on both the private store and the lineage published on the pull + # request -- still before any provider execution below. + _require_independent_review( + "claude", + repo, + pr_number, + pr_meta, + head_sha_start, + authorities=decision_authorities, + fetch_comments=lambda: fetch_issue_comments( + repo, pr_number, token=config.github_token + ), + ) budget_was_explicit = bool(str(config.max_budget_usd or "").strip()) effective_budget_usd = code_mower_audit_limits.resolve_audit_budget_usd( diff_context.included_diff_bytes, diff --git a/src/code_mower/codex_audit_pr.py b/src/code_mower/codex_audit_pr.py index d278d15c..ad898758 100644 --- a/src/code_mower/codex_audit_pr.py +++ b/src/code_mower/codex_audit_pr.py @@ -1859,6 +1859,50 @@ def _codex_context_omission_notice_from_diagnostics(diagnostics: str) -> str: # ----- Orchestration ----- +def _require_independent_review( + lane, repo, pr_number, pr_meta, head_sha, *, authorities=(), fetch_comments=None +): + """Admit ``lane`` against verified contribution lineage, or raise. + + Evidence is both the host's configured private store and the bounded + lineage published on the pull request by a configured decision authority. + An independent reviewer host recorded none of the contributions it has to + reason about, so its private store is empty and reading only that would + answer "no takeover happened" exactly where a takeover is the question. + + Imported lazily so the direct-script execution fallback this module + supports does not have to resolve the package layout at import time. + """ + + try: + from code_mower.builder_lineage import LineageError + from code_mower.provider_runners.lineage import ( + identity_with_lane_floor, load_identity, require_reviewer_lane, reviewer_evidence, + ) + except ImportError: # pragma: no cover - direct script execution fallback + from builder_lineage import LineageError # type: ignore + from provider_runners.lineage import ( # type: ignore + identity_with_lane_floor, load_identity, require_reviewer_lane, reviewer_evidence, + ) + # Real recorded evidence, not the resolver's empty default: an admission + # decided on no episodes cannot see a takeover, which is the whole point. + # Unreadable evidence refuses rather than reviewing on a guess. + try: + episodes = reviewer_evidence( + repo, pr_number, authorities=authorities, fetch_comments=fetch_comments + ) + except LineageError as exc: + raise RuntimeError( + f"{lane} reviewer lane is not admitted for {repo}#{pr_number} at " + f"{str(head_sha)[:12]}: lineage_unreadable; {exc}" + ) from None + return require_reviewer_lane( + lane, repo, pr_number, pr_meta, head_sha, + episodes=episodes, + identity=identity_with_lane_floor(load_identity(), lane), + ) + + def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult: """End-to-end audit of one PR. Creates a temporary worktree at the PR head, runs Codex review, structures its verdict, formats + posts a @@ -1878,6 +1922,11 @@ def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult: config = replace(config, progress=AuditProgress("codex-audit")) pr_meta = fetch_pull_request(repo, pr_number, token=config.github_token) head_sha_start = pr_meta["head"]["sha"] + # Admission runs on trusted metadata at the exact head, before the provider + # is launched, so a contributing lane never spends a run reviewing its own + # diff and never produces a verdict it is not independent enough to give. + # It needs the decision authorities named by the immutable base #955 pins, + # so the call itself is below that pin and above every provider launch. config.progress.emit( "audit", @@ -1941,6 +1990,20 @@ def audit_pr(config: AuditConfig, repo: str, pr_number: int) -> AuditResult: config.decision_authorities, trusted_ref=config.base_ref, ) + # Reviewer independence, decided on the authorities the pinned base names + # and on both the private store and the lineage published on the pull + # request -- still before any provider execution below. + _require_independent_review( + "codex", + repo, + pr_number, + pr_meta, + head_sha_start, + authorities=decision_authorities, + fetch_comments=lambda: fetch_issue_comments( + repo, pr_number, token=config.github_token + ), + ) private_context = context_audit.prepare( repository=repo, pr=pr_number, head=head_sha_start, host="codex", authorities=decision_authorities, revision=config.context_revision, diff --git a/src/code_mower/controller.py b/src/code_mower/controller.py index ce953645..a36dda87 100644 --- a/src/code_mower/controller.py +++ b/src/code_mower/controller.py @@ -266,38 +266,130 @@ def _has_pending(pr: Mapping[str, Any]) -> bool: ) +def _reviewer_eligibility( + config: Mapping[str, Any], reviewer: Mapping[str, str] +) -> dict[str, str]: + """Effective role eligibility for one reviewer lane, decided separately. + + Independence and eligibility answer different questions and neither implies + the other: a qualified reviewer that wrote part of this diff still may not + gate it, and a lane that touched nothing is not made eligible by that. They + are consulted as two decisions so a lane dropped for one reason is never + reported as dropped for the other. + """ + + from .role_eligibility import PRODUCTS, ConfigError, decide_role + + lanes = config.get("lanes") if isinstance(config.get("lanes"), Mapping) else {} + raw_lane = lanes.get(reviewer["lane_id"]) if isinstance(lanes, Mapping) else None + lane = raw_lane if isinstance(raw_lane, Mapping) else {} + product = _text(lane.get("provider")) or _text(reviewer["author_lane"]) + if product not in PRODUCTS: + # Lanes outside the role-eligibility products keep their existing + # repository authority; this seam does not newly restrict them. + return {"status": "eligible", "reason": "repository_policy"} + try: + decision = decide_role( + product, + "reviewer", + transport=_text(lane.get("transport")) or None, + config=config, + merge_authority=True, + ) + except ConfigError: + return {"status": "ineligible", "reason": "role_request_invalid"} + return {"status": _text(decision.get("status")), "reason": _text(decision.get("reason"))} + + def _reviewer_outcomes( pr: Mapping[str, Any], config: Mapping[str, Any], -) -> tuple[list[dict[str, Any]], bool, bool, str]: +) -> tuple[list[dict[str, Any]], bool, bool, str, dict[str, Any]]: + """Select reviewers from verified exact-head lineage, not one active label. + + Every lane that contributed to the current diff is excluded, not just the + one the newest ``builder:*`` label names. Lineage that does not resolve at + this head selects nobody and blocks with the resolver's owner action: a + reviewer admitted on unresolved lineage may be reviewing its own work. + """ + labels = pr.get("labels") if isinstance(pr.get("labels"), Mapping) else {} done = set(labels.get("done") or []) blocked = set(labels.get("blocked") or []) builder_lane = _builder_lane_from_labels(labels.get("builder") or [], config) + + raw_lineage = pr.get("builder_lineage") + lineage = raw_lineage if isinstance(raw_lineage, Mapping) else None + contributors: tuple[str, ...] = () + lineage_block = "" + lineage_status = "absent" + if lineage is not None: + lineage_status = _text(lineage.get("status")) + if lineage_status == "resolved": + contributors = tuple( + lane for lane in (_text(item) for item in (lineage.get("contributors") or [])) if lane + ) + builder_lane = _text(lineage.get("current_writer")) or builder_lane + else: + lineage_block = _text(lineage.get("owner_action")) or ( + "resolve the builder contribution lineage for this head" + ) + excluded_author_lane = "" - outcomes = [] - for reviewer in _merge_reviewers(config): - excluded = ( - _author_never_gates(config) - and builder_lane - and reviewer["author_lane"] == builder_lane - ) - if excluded: - excluded_author_lane = reviewer["author_lane"] - continue - verdict = "PASS" if reviewer["done_label"] in done else "MISSING" - if reviewer["blocked_label"] and reviewer["blocked_label"] in blocked: - verdict = "BLOCKED" - outcomes.append( - { - "lane_id": reviewer["author_lane"], - "config_lane_id": reviewer["lane_id"], - "verdict": verdict, - "promoted": True, - } + outcomes: list[dict[str, Any]] = [] + ineligible: list[str] = [] + if not lineage_block: + for reviewer in _merge_reviewers(config): + lane = reviewer["author_lane"] + contributed = lane in contributors if contributors else bool( + builder_lane and lane == builder_lane + ) + if _author_never_gates(config) and contributed: + excluded_author_lane = lane + continue + eligibility = _reviewer_eligibility(config, reviewer) + if eligibility["status"] == "ineligible": + ineligible.append(lane) + continue + verdict = "PASS" if reviewer["done_label"] in done else "MISSING" + if reviewer["blocked_label"] and reviewer["blocked_label"] in blocked: + verdict = "BLOCKED" + outcomes.append( + { + "lane_id": lane, + "config_lane_id": reviewer["lane_id"], + "verdict": verdict, + "promoted": True, + } + ) + + # Two different owner actions, deliberately not merged. Unresolved or + # contradictory lineage means the evidence itself cannot be trusted, and + # the fix is to re-record it. A lineage that resolved perfectly well but + # left no qualified independent reviewer is a *correct* decision about a + # configuration gap, and the fix is to configure another lane. Reporting + # the second as the first sends the owner to repair a record that is fine. + reviewer_block = "" + if not lineage_block and not outcomes and (excluded_author_lane or ineligible): + reviewer_block = ( + "no qualified independent reviewer lane remains for this head; " + "configure one that did not contribute to this diff" ) - passed = bool(outcomes) and all(outcome["verdict"] == "PASS" for outcome in outcomes) - return outcomes, bool(excluded_author_lane), passed, builder_lane + passed = ( + not lineage_block + and not reviewer_block + and bool(outcomes) + and all(outcome["verdict"] == "PASS" for outcome in outcomes) + ) + projection = { + "status": lineage_status, + "contributors": list(contributors), + "current_writer": builder_lane, + "ineligible_reviewers": ineligible, + "owner_action": lineage_block, + "reviewer_action": reviewer_block, + } + return outcomes, bool(excluded_author_lane), passed, builder_lane, projection def _pr_priority(pr: Mapping[str, Any]) -> tuple[int, int]: @@ -365,7 +457,13 @@ def _pr_decision( owner_label = _owner_label(config) configured_reviewers = _merge_reviewers(config) gate_state = _gate_state(pr) - reviewer_outcomes, author_lane_excluded, reviewers_passed, builder_lane = _reviewer_outcomes(pr, config) + ( + reviewer_outcomes, + author_lane_excluded, + reviewers_passed, + builder_lane, + lineage, + ) = _reviewer_outcomes(pr, config) base = { "pr_number": pr.get("number"), "pr_url": pr.get("url", ""), @@ -376,9 +474,34 @@ def _pr_decision( "gate_status": gate_state, "reviewer_outcomes": reviewer_outcomes, "author_lane_excluded": author_lane_excluded, + "builder_lineage_status": lineage["status"], + "builder_contributors": lineage["contributors"], "promoted_reviewers_passed": reviewers_passed, "would_mutate": False, } + # Unresolved lineage, or no qualified independent reviewer left after + # excluding every contributor, is one owner action -- not a merge decision + # taken on a reviewer that may have written the diff. + if lineage["owner_action"]: + return { + **base, + "decision_state": "owner_action", + "next_action": "resolve builder contribution lineage", + "next_detail": lineage["owner_action"], + "stop_condition": "builder_lineage_unresolved", + "owner_action_kind": "builder_lineage", + "merge_method": "", + } + if lineage["reviewer_action"]: + return { + **base, + "decision_state": "owner_action", + "next_action": "configure peer reviewer lanes", + "next_detail": lineage["reviewer_action"], + "stop_condition": "reviewer_lanes_missing", + "owner_action_kind": "reviewer_lanes_missing", + "merge_method": "", + } if labels.get("blocked"): return { **base, diff --git a/src/code_mower/devin_cli_audit_pr.py b/src/code_mower/devin_cli_audit_pr.py index d78cffcd..ceae3315 100644 --- a/src/code_mower/devin_cli_audit_pr.py +++ b/src/code_mower/devin_cli_audit_pr.py @@ -560,6 +560,85 @@ def _is_excluded_author(author: str) -> bool: return author.strip().lower() in default_authors +DEVIN_REVIEWER_LANE = "devin" + + +def _require_independent_devin_review( + config, pr_meta, head_sha: str, pr_author: str, *, fetch_comments=None +) -> dict: + """Admit the Devin reviewer lane against verified lineage for this head. + + Evidence is the host's configured private store *and* the bounded lineage + published on the pull request by a configured decision authority, because a + reviewer host that recorded no contribution has an empty store and would + otherwise conclude that no takeover ever happened. + + Raises :class:`AuthorExcludedError` so existing callers keep their exit + handling; the message carries only bounded metadata and one owner action. + """ + + from . import decisions as code_mower_decisions + from .builder_lineage import LineageError + from .provider_runners.lineage import ( + ReviewerNotIndependent, + identity_with_lane_floor, + load_identity, + require_independent_reviewer, + reviewer_evidence, + ) + + if fetch_comments is None: + from .provider_runners.github_pr import fetch_issue_comments + + def fetch_comments(): + return fetch_issue_comments( + config.repo, config.pr_number, token=config.github_token + ) + + if pr_author and _is_excluded_author(pr_author): + # Kept as a floor, not as the decision: the configured Devin account + # list can only add exclusion, never admit a lane the lineage excludes. + raise AuthorExcludedError( + f"PR author {pr_author!r} is excluded from the Devin CLI reviewer lane" + ) + # An unconfigured or malformed checkout must not become more permissive than + # the deny list this wrapper shipped with: Devin's own accounts and label + # are named whatever the identity file says, so the shared resolver can + # still see Devin as a contributor. + identity = identity_with_lane_floor(load_identity(), DEVIN_REVIEWER_LANE) + try: + episodes = reviewer_evidence( + config.repo, + config.pr_number, + authorities=code_mower_decisions.decision_authorities_from_env(), + fetch_comments=fetch_comments, + ) + except LineageError as exc: + raise AuthorExcludedError( + f"Devin CLI reviewer lane is not admitted for {config.repo}" + f"#{config.pr_number} at {head_sha[:12]}: lineage_unreadable; {exc}" + ) from None + try: + return require_independent_reviewer( + DEVIN_REVIEWER_LANE, + repo=config.repo, + pr_number=config.pr_number, + pr_meta=pr_meta, + head_sha=head_sha, + episodes=episodes, + identity=identity, + ) + except ReviewerNotIndependent as exc: + if exc.reason == "lineage_conflict" and pr_author and _is_excluded_author(pr_author): + raise AuthorExcludedError( + f"PR author {pr_author!r} is excluded from the Devin CLI reviewer lane" + ) from None + raise AuthorExcludedError( + f"Devin CLI reviewer lane is not admitted for {config.repo}" + f"#{config.pr_number} at {head_sha[:12]}: {exc.reason}; {exc.owner_action}" + ) from None + + @dataclass class DevinCliVerdict: verdict: str # PASS, BLOCKED, or UNKNOWN @@ -1157,11 +1236,14 @@ def _do_audit_pr(config: AuditConfig) -> AuditResult: if not pr_head_sha: raise ValueError("GitHub pull request response did not include head.sha") + # Independence is decided from verified contribution lineage at this exact + # head, not from who opened the PR. The historical author deny list stays as + # one input so a Devin-authored PR with no lineage evidence still fails + # closed, but a Devin PR taken over by another lane no longer excludes the + # lane that actually wrote the diff -- and no longer admits Devin when Devin + # contributed under a different opener. pr_author = str(((pr_meta.get("user") or {}).get("login")) or "").strip() - if pr_author and _is_excluded_author(pr_author): - raise AuthorExcludedError( - f"PR author {pr_author!r} is excluded from the Devin CLI reviewer lane" - ) + _require_independent_devin_review(config, pr_meta, pr_head_sha, pr_author) if str(pr_meta.get("head", {}).get("repo", {}).get("full_name") or "") != config.repo: raise ValueError("PR head repository does not match the target repository") diff --git a/src/code_mower/devin_review.py b/src/code_mower/devin_review.py index f0a689d4..12408728 100644 --- a/src/code_mower/devin_review.py +++ b/src/code_mower/devin_review.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import re from dataclasses import dataclass, field from copy import deepcopy from pathlib import Path @@ -66,6 +67,17 @@ def unique(pairs): raise RemoteError('invalid_review_output') from None +#: A Git branch name as it appears in authenticated pull request metadata. +#: Deliberately narrower than Git's own rules: this value only ever travels +#: into an exact-match binding, so anything exotic is a mismatch, not a name. +BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,254}\Z") + +#: How many published lineage markers the embedding orchestrator may carry. +#: The producer republishes the whole chain each round, so a real pull request +#: accumulates several; an unbounded list is refused rather than parsed. +MAX_REVIEW_LINEAGE_MARKERS = 32 + + @dataclass(frozen=True, repr=False) class ReviewInput: repository: str @@ -74,6 +86,77 @@ class ReviewInput: author: str context: dict changed_files: tuple[str, ...] + #: Authenticated pull request metadata the orchestrator fetched. These are + #: part of the immutable binding, not decoration: lineage is bound to a + #: branch, and an episode resolved without one is an episode resolved + #: against whatever branch happens to be in the record. + branch: str = '' + labels: tuple[str, ...] = () + #: Bodies of comments the orchestrator already established as written by a + #: configured decision authority. The marker inside is a transport for + #: bounded metadata and confers no authority of its own; carrying the whole + #: body keeps parsing strict and keeps this adapter from having to invent a + #: second episode format. + lineage_markers: tuple[str, ...] = () + + def published_lineage(self) -> tuple: + """Episodes parsed from the trusted markers the orchestrator carried.""" + + from .builder_lineage import episodes_from_comment_body + + if len(self.lineage_markers) > MAX_REVIEW_LINEAGE_MARKERS: + raise ValueError('review_lineage_unbounded') + return tuple( + episode + for body in self.lineage_markers + for episode in episodes_from_comment_body(body) + ) + + def pr_metadata(self) -> dict: + """The trusted metadata shape the shared resolver expects. + + Branch and labels are carried through rather than dropped, so the + resolver binds episodes to this exact repository, pull request, branch + and head instead of accepting any record that names the pull request. + """ + + return { + 'user': {'login': self.author}, + 'head': {'ref': self.branch, 'sha': self.head}, + 'labels': [{'name': name} for name in self.labels], + } + + def lineage_admits(self) -> bool: + """Whether verified lineage admits the Devin reviewer lane at this head. + + The author deny list below stays as a floor, but it only ever sees the + opener. This consults the same shared seam the direct wrappers use, so a + PR another lane opened and Devin later took over is refused too. + Unreadable or unresolved evidence is not admission. + """ + + from .builder_lineage import LineageError + from .provider_runners.lineage import ( + identity_with_lane_floor, load_identity, reviewer_admission, trusted_episodes, + ) + + try: + episodes = trusted_episodes( + self.repository, self.pr, published=self.published_lineage() + ) + except (LineageError, OSError, ValueError): + return False + return bool( + reviewer_admission( + 'devin', + repo=self.repository, + pr_number=self.pr, + pr_meta=self.pr_metadata(), + head_sha=self.head, + episodes=episodes, + identity=identity_with_lane_floor(load_identity(), 'devin'), + )['admitted'] + ) def check(self, current: ReviewInput) -> None: try: @@ -83,6 +166,14 @@ def check(self, current: ReviewInput) -> None: and LOGIN.fullmatch(self.author) and not _is_excluded_author(self.author) and self.author.lower() not in {'devin-ai-integration', 'devin-ai-integration[bot]', 'devin-cli-audit-bot', 'devin-cli-audit-bot[bot]'} + and isinstance(self.branch, str) and len(self.branch) <= 255 + and (not self.branch or BRANCH.fullmatch(self.branch)) + and isinstance(self.labels, tuple) and len(self.labels) <= 64 + and all(isinstance(name, str) and 0 < len(name) <= 128 for name in self.labels) + and isinstance(self.lineage_markers, tuple) + and len(self.lineage_markers) <= MAX_REVIEW_LINEAGE_MARKERS + and all(isinstance(body, str) for body in self.lineage_markers) + and self.lineage_admits() and isinstance(self.changed_files, tuple) and all(isinstance(p, str) and p and not p.startswith(('/', '\\')) and '\\' not in p and '..' not in p.split('/') for p in self.changed_files) diff --git a/src/code_mower/init.py b/src/code_mower/init.py index ba970690..8f63cdca 100644 --- a/src/code_mower/init.py +++ b/src/code_mower/init.py @@ -226,6 +226,16 @@ "product-support-helper", "0644", ), + ( + # audit_labeler_lib imports this for exact-head builder lineage. A gate + # runner in a generated product repository has no Code Mower package + # installed, so the dependency has to travel with the helper or the + # import fails and the gate cannot evaluate at all. + "tools/builder_lineage.py", + "builder_lineage.py", + "product-support-helper", + "0644", + ), ( "tools/decisions.py", "decisions.py", @@ -1343,6 +1353,20 @@ def _author_exclusion_payload( payload["labels"].setdefault(f"builder:{author_lane}", author_lane) if raw_author_lane != author_lane: payload["labels"].setdefault(f"builder:{raw_author_lane}", author_lane) + # Narrow resolver context for the generated gate and labelers. Branch + # identity is the one binding the shared resolver cannot derive from labels + # and authors, and `require_verified_lineage` says when the identity-only + # fallback is not an acceptable answer: with a takeover-capable lane set, + # a PR whose configured branch identity and recorded lineage disagree is an + # owner action, not a lane name picked from whichever signal is present. + payload["branch_prefixes"] = _identity_section( + identity, "branch_prefixes", canonicalize_lanes=True + ) + for lane in sorted({lane for lane in payload["labels"].values() if lane}): + payload["branch_prefixes"].setdefault(f"{lane}/", lane) + payload["require_verified_lineage"] = bool(payload["enabled"]) and ( + len({lane for lane in payload["labels"].values() if lane}) > 1 + ) return payload diff --git a/src/code_mower/lane_delivery.py b/src/code_mower/lane_delivery.py index b145ee38..c29e62e4 100644 --- a/src/code_mower/lane_delivery.py +++ b/src/code_mower/lane_delivery.py @@ -114,6 +114,10 @@ PR_REF_RE = re.compile(r"^(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#(?P[0-9]+)$") LANE_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$") REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +#: Head branch name as GitHub reports it. Kept identical to the lineage +#: episode contract's own branch rule, because a snapshot branch that the +#: episode would refuse is not evidence this layer should carry forward. +STATE_BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]{1,200}$") #: Prompt text that would push a provider into discovering or reading auth #: material. Rules are matched by name so a report never echoes the match. @@ -198,12 +202,22 @@ class TargetState: otherwise indistinguishable from real absence and would fabricate a transition. Snapshots loaded from a file must state it explicitly; see :func:`_load_state`. + + ``branch`` is the pull request's head branch as the same authenticated read + that produced ``head_sha`` reported it. It travels with the head rather than + being re-resolved later because the two have to describe one observation: + recording provenance against a branch resolved from anywhere else would bind + an episode to something the snapshot never saw. It is empty when there is no + PR, and also when an older producer wrote the snapshot before this field + existed -- those snapshots stay loadable, and every consumer that needs the + binding refuses rather than substituting a branch of its own. """ kind: str number: str pr_number: str = "" head_sha: str = "" + branch: str = "" pr_state: str = "" labels: tuple[str, ...] = () runner_comment_id: str = "" @@ -223,6 +237,9 @@ def from_mapping(cls, payload: Mapping[str, Any]) -> "TargetState": head_sha = _text(payload.get("head_sha")).lower() if head_sha and not SHA_RE.match(head_sha): raise LaneDeliveryError("state head_sha must be a 40-character sha") + branch = _text(payload.get("branch")) + if branch and not STATE_BRANCH_RE.match(branch): + raise LaneDeliveryError("state branch must be a plain head branch name") labels = tuple( sorted({_text(label) for label in payload.get("labels") or () if _text(label)}) ) @@ -234,6 +251,7 @@ def from_mapping(cls, payload: Mapping[str, Any]) -> "TargetState": number=number, pr_number=pr_number, head_sha=head_sha, + branch=branch, pr_state=_text(payload.get("pr_state")).upper(), labels=labels, runner_comment_id=_text(payload.get("runner_comment_id")), @@ -246,6 +264,7 @@ def as_dict(self) -> dict[str, Any]: "number": self.number, "pr_number": self.pr_number, "head_sha": self.head_sha, + "branch": self.branch, "pr_state": self.pr_state, "labels": list(self.labels), "runner_comment_id": self.runner_comment_id, @@ -1140,6 +1159,14 @@ def _add_classify_parser(subparsers: Any) -> None: classify.add_argument("--elapsed-seconds", type=float) classify.add_argument("--user-interventions", type=int) classify.add_argument("--handoff", default="", help="Validated handoff JSON path.") + classify.add_argument( + "--handoff-state-dir", + type=Path, + help=( + "Private handoff intent store. With --handoff, a delivered unit " + "records its contribution episode here." + ), + ) classify.add_argument("--output", type=Path, help="Write the outcome event here.") classify.add_argument("--force", action="store_true") classify.add_argument("--json", action="store_true") @@ -1186,6 +1213,389 @@ def _add_handoff_parser(subparsers: Any) -> None: handoff.add_argument("--reserve-launch", action="store_true", help="Claim the verified destination launch once") +def _add_lineage_parser(subparsers: Any) -> None: + lineage = subparsers.add_parser( + "lineage", + help="Resolve recorded builder lineage and reconcile the active builder label.", + ) + lineage.add_argument("--repo", required=True) + lineage.add_argument("--pr", required=True) + lineage.add_argument("--branch", default="") + lineage.add_argument("--head", required=True, help="The exact head the caller pinned.") + lineage.add_argument("--label", dest="labels", action="append", default=[]) + lineage.add_argument("--author", default="", help="Pull request opener login.") + lineage.add_argument("--state-dir", type=Path, help="Private handoff intent store.") + lineage.add_argument( + "--identity-json", + default="", + help="Author-exclusion identity contract; defaults to the runner environment.", + ) + lineage.add_argument( + "--publish", + action="store_true", + help="Publish gate-trusted episode metadata on the pull request first.", + ) + lineage.add_argument( + "--reconcile-labels", + action="store_true", + help="Move the active builder label to the verified current writer.", + ) + lineage.add_argument("--json", action="store_true") + + +def _gh_head(repo: str, number: str) -> str: + result = subprocess.check_output( + ["gh", "pr", "view", number, "--repo", repo, "--json", "headRefOid"], + timeout=30, text=True, stderr=subprocess.DEVNULL, + ) + return _text(json.loads(result).get("headRefOid")) + + +def _gh_apply_labels(repo: str, number: str, add: Iterable[str], remove: Iterable[str]) -> None: + command = ["gh", "pr", "edit", number, "--repo", repo] + for label in add: + command += ["--add-label", label] + for label in remove: + command += ["--remove-label", label] + subprocess.run(command, timeout=60, check=True, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + +def _gh_comment_bodies(repo: str, number: str) -> tuple[dict[str, Any], ...]: + """Existing pull request comments, with the author each one was posted by. + + The author travels with the body because publication idempotency is decided + under the consumers' trust rule: an identical marker from an account no + consumer reads is not a publication. + """ + + from . import builder_lineage + + result = subprocess.check_output( + ["gh", "pr", "view", number, "--repo", repo, "--json", "comments"], + timeout=60, text=True, stderr=subprocess.DEVNULL, + ) + # `gh pr view --json comments` embeds one list under the pull request -- + # not the gate's slurped array of page arrays, and not REST's comment + # count. Whatever it is, it is validated before it is normalised: `or []` + # made a null or object response an empty history, and filtering + # non-objects out dropped records that could have carried the marker. + payload = json.loads(result).get("comments") + if payload is None: + raise LaneDeliveryError( + f"gh pr view returned no comments field for {repo}#{number}" + ) + validated = builder_lineage.require_comment_list( + payload, what=f"pull request comments for {repo}#{number}" + ) + return tuple( + { + "user": { + "login": str( + ((item.get("author") or item.get("user") or {}).get("login")) or "" + ) + }, + "body": str(item.get("body") or ""), + } + for item in validated + ) + + +def _gh_publish_comment(repo: str, number: str, body: str) -> None: + subprocess.run(["gh", "pr", "comment", number, "--repo", repo, "--body", body], + timeout=60, check=True, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + +def publish_lineage_evidence( + *, + repo: str, + pr_number: str, + branch: str, + head_sha: str, + episodes: Sequence[Any], + opener_lane: str = "", + label_lanes: Sequence[str] = (), + existing_bodies: Callable[[], Sequence[Any]], + publish: Callable[[str], None], + trusted_author: Callable[[str], bool] | None = None, +) -> dict: + """Put the evidence the GitHub gate reads where the gate can read it. + + The runner's own store is private to the machine that recorded it, while + the gate resolves lineage exclusively from hidden markers on already + trusted comments. Recording a takeover without publishing it therefore + leaves the gate looking at a Devin author, a reconciled Codex label and no + episodes at all -- a conflict, when the truth is an admissible independent + reviewer. + + Only lineage that resolves at the pinned head is published, so an + unverified or stale record never becomes public evidence. The payload is + the bounded metadata contract: lane names, a repository slug, a PR number, + a branch and commit shas. Publishing is idempotent -- an identical marker + already present is left alone rather than repeated. + + Idempotency is decided under the *consumers'* trust rule, not on the bare + text. The gate, the labelers and every reviewer read markers only from the + repository's configured decision authorities, so an identical body posted + by anyone else is evidence nobody will read: treating it as "already + published" would let an untrusted commenter suppress the publication this + path exists to guarantee. By the same rule a successful comment POST is not + by itself proof, so the comment is read back and the publication only + counts when a trusted author now carries the marker. + + ``trusted_author`` is therefore required, not optional. With no configured + decision authority there is no account whose marker any consumer would + read, so publishing would leave evidence nobody can use while the caller + went on to move the builder label -- exactly the conflict this path exists + to prevent. That case fails here, before anything is posted. + """ + + from . import builder_lineage + + if trusted_author is None: + # Nothing is posted and nothing is reconciled: the caller is told what + # to configure instead of writing evidence no consumer would read. + return {"published": False, "duplicate": False, + "reason": "no_decision_authority", + "owner_action": ( + "configure decision authorities before publishing builder " + "lineage: with none configured no consumer trusts any " + "published marker" + )} + lineage = builder_lineage.resolve_lineage( + repo=repo, pr_number=pr_number, branch=branch, head_sha=head_sha, + episodes=episodes, opener_lane=opener_lane, label_lanes=label_lanes, + ) + if lineage.status != "resolved" or not episodes: + return {"published": False, "reason": f"lineage_{lineage.status}", + "owner_action": lineage.owner_action} + marker = builder_lineage.lineage_comment_marker(tuple(episodes)) + _assert_safe_metadata(json.loads(marker.split(None, 2)[2].rsplit("-->", 1)[0].strip()), + path="lineage_marker") + + def _published_episodes() -> tuple: + """Every episode the trusted published history actually carries. + + The expected marker text appearing *somewhere* is not a publication. + A body can hold that substring beside a broken marker, and two trusted + comments can each carry a different chain -- in both cases the history + consumers will read is not the one being published. So the trusted + comments are parsed under the strict framing rule and their episodes + are collected whole; an unreadable one raises rather than being + skipped past. + """ + + collected: list = [] + for item in existing_bodies() or (): + if isinstance(item, Mapping): + login = str(((item.get("user") or {}).get("login")) or "") + body = str(item.get("body") or "") + else: + login, body = "", str(item) + if builder_lineage.LINEAGE_MARKER not in body: + continue + if not trusted_author(login): + continue + collected.extend(builder_lineage.episodes_from_comment_body(body)) + return tuple(collected) + + def _published_state() -> tuple[str, str]: + """``(state, detail)`` for the trusted history at this exact head.""" + + try: + collected = _published_episodes() + except builder_lineage.LineageError as exc: + return "unreadable", str(exc) + if not collected: + return "absent", "" + resolved = builder_lineage.resolve_lineage( + repo=repo, pr_number=pr_number, branch=branch, head_sha=head_sha, + episodes=collected, opener_lane=opener_lane, label_lanes=label_lanes, + ) + if resolved.status != "resolved": + return "conflicting", resolved.reason + if ( + resolved.current_writer != lineage.current_writer + or resolved.episodes != lineage.episodes + or tuple(resolved.contributors) != tuple(lineage.contributors) + ): + return "conflicting", "published lineage describes a different chain" + return "published", "" + + state, detail = _published_state() + if state == "unreadable": + # Whatever is already public cannot be read. Adding to it would leave + # two histories nobody can reconcile, and moving the label would rest + # on the one that could not be read. + return {"published": False, "duplicate": False, + "reason": "existing_lineage_unreadable", "detail": detail, + "owner_action": ( + "the builder lineage already published on this pull " + "request cannot be read; correct or remove it before " + "publishing again" + )} + if state == "conflicting": + return {"published": False, "duplicate": False, + "reason": "existing_lineage_conflicts", "detail": detail, + "owner_action": ( + "the builder lineage already published on this pull " + "request describes a different chain at this head; " + "reconcile the two before publishing again" + )} + if state == "published": + return {"published": False, "duplicate": True, "reason": "already_published"} + publish( + "Builder contribution lineage for this head, published so the gate and " + "every reviewer resolve the same verified evidence.\n\n" + f"- current writer: `{lineage.current_writer}`\n" + f"- contributors: {', '.join('`' + lane + '`' for lane in lineage.contributors)}\n" + f"- head: `{lineage.head_sha}`\n\n" + marker + ) + # The readback asks the same semantic question, not whether the text + # landed: what a consumer now resolves from the trusted history has to be + # the lineage that was just published. + state, detail = _published_state() + if state != "published": + # Either the comment went up under an account the consumers do not + # trust, or what is now public does not resolve to this chain. Either + # way the evidence is not readable as intended by everyone who needs + # it, and reporting it as published would move the builder label onto + # lineage the gate cannot see -- the exact conflict this path prevents. + return {"published": False, "duplicate": False, + "reason": "publication_author_untrusted", "detail": detail, + "owner_action": ( + "publish builder lineage from a configured decision " + "authority, or add the publishing account to them" + )} + return {"published": True, "duplicate": False, "reason": "published", + "episodes": len(tuple(episodes))} + + +def _lineage_main(args: argparse.Namespace, *, + head: Callable[[str, str], str] = _gh_head, + labels: Callable[..., None] = _gh_apply_labels, + comment_bodies: Callable[[str, str], Sequence[str]] = _gh_comment_bodies, + publish_comment: Callable[[str, str, str], None] = _gh_publish_comment) -> int: + """Report the resolved lineage, and optionally reconcile the active label. + + Reading is always safe; the mutation happens only when it is asked for, and + only when the resolver named one current writer at the exact head the caller + pinned. Unresolved lineage prints its owner action and changes nothing. + """ + + from . import builder_lineage, lane_handoff + from .provider_runners.lineage import load_identity + + repo = _text(args.repo) + number = _text(args.pr) + identity = load_identity(args.identity_json or None) + episodes: tuple[Any, ...] = () + root = args.state_dir or lane_handoff.configured_root() + try: + episodes = builder_lineage.load_episodes( + lane_handoff.lineage_root(root), repo, number + ) + except builder_lineage.LineageError as exc: + raise LaneDeliveryError(str(exc)) from None + opener_lane, _ = builder_lineage.lanes_from_identity( + identity=identity, labels=args.labels, author=args.author + ) + published: dict | None = None + if args.publish: + _, publish_label_lanes = builder_lineage.lanes_from_identity( + identity=identity, labels=args.labels, author=args.author + ) + # One trust contract, shared with the gate, the labelers and every + # reviewer: the repository's configured decision authorities and + # nobody else. With none configured there is nobody to publish under + # and nobody whose marker a consumer would read, so this stops before + # the comment and before the label -- an unreadable publication plus a + # moved label is the conflict this whole path exists to avoid. + from .audit_labeler_lib import lineage_marker_author_trust + from .decisions import decision_authorities_from_env + + authorities = decision_authorities_from_env() + if not authorities: + payload = { + "schema": builder_lineage.SCHEMA, "status": "blocked", + "reason": "lineage_no_decision_authority", "head_sha": args.head, + "current_writer": "", "add": [], "remove": [], + "applied": False, "published": False, + "owner_action": ( + "configure decision authorities before publishing builder " + "lineage: with none configured no consumer trusts any " + "published marker" + ), + } + _assert_safe_metadata(payload, path="lineage") + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"lineage {payload['status']}: {payload['reason']}") + return 3 + published = publish_lineage_evidence( + repo=repo, pr_number=number, branch=args.branch, head_sha=args.head, + episodes=episodes, opener_lane=opener_lane, + label_lanes=publish_label_lanes, + existing_bodies=lambda: comment_bodies(repo, number), + publish=lambda body: publish_comment(repo, number, body), + trusted_author=lineage_marker_author_trust(authorities=authorities), + ) + if not (published["published"] or published.get("duplicate")): + # The label says who may write next; the published episodes are how + # everyone else verifies it. Moving the label without them is the + # exact state that produces a gate conflict, so stop here instead. + payload = {"schema": builder_lineage.SCHEMA, "status": "blocked", + "reason": "lineage_unpublished", "head_sha": args.head, + "current_writer": "", "add": [], "remove": [], + "applied": False, "published": False, + "owner_action": published.get("owner_action") + or "publish verified builder lineage before reconciling the label"} + _assert_safe_metadata(payload, path="lineage") + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"lineage {payload['status']}: {payload['reason']}") + return 3 + + if args.reconcile_labels: + payload = builder_lineage.reconcile_active_builder_label( + repo=repo, + pr_number=number, + branch=args.branch, + head_sha=args.head, + current_labels=args.labels, + episodes=episodes, + identity=identity, + opener_lane=opener_lane, + observe_head=lambda: head(repo, number), + apply_labels=lambda add, remove: labels(repo, number, add, remove), + ) + else: + _, label_lanes = builder_lineage.lanes_from_identity( + identity=identity, labels=args.labels, author=args.author + ) + payload = builder_lineage.resolve_lineage( + repo=repo, + pr_number=number, + branch=args.branch, + head_sha=args.head, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + ).as_dict() + if published is not None: + payload = dict(payload) + payload["published"] = bool(published["published"] or published.get("duplicate")) + _assert_safe_metadata(payload, path="lineage") + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"lineage {payload.get('status')}: {payload.get('reason')}") + return 0 if payload.get("status") in {"resolved", "current", "reconcile"} else 3 + + def _add_scan_prompt_parser(subparsers: Any) -> None: scan = subparsers.add_parser( "scan-prompt", @@ -1226,6 +1636,7 @@ def main(argv: list[str] | None = None) -> int: _add_classify_parser(subparsers) _add_transition_parser(subparsers) _add_handoff_parser(subparsers) + _add_lineage_parser(subparsers) _add_scan_prompt_parser(subparsers) _add_supervise_parser(subparsers) admit = subparsers.add_parser("admit-builder", help="Check role admission against the trusted fresh-base checkout") @@ -1245,6 +1656,8 @@ def main(argv: list[str] | None = None) -> int: return _transition_main(args) if args.command == "handoff": return _handoff_main(args) + if args.command == "lineage": + return _lineage_main(args) if args.command == "scan-prompt": return _scan_prompt_main(args) if args.command == "supervise": @@ -1329,6 +1742,39 @@ def _classify_main(args: argparse.Namespace) -> int: target_branch=_text(payload.get("target_branch")), ) + # Provenance is recorded from the delivery that actually happened, not from + # the handoff that authorized it. An undelivered or unvalidated round adds + # no episode, so a lane that was launched and wrote nothing never appears as + # a contributor to the diff. + lineage_result = None + if args.handoff_state_dir is not None: + from . import lane_handoff + + if handoff is not None: + lineage_result = lane_handoff.record_contribution( + handoff, + args.handoff_state_dir, + resulting_head=after.head_sha, + delivered=outcome.delivered, + ) + elif args.lane and args.repo and after.kind == "pr" and after.number: + # An ordinary fix round after a takeover carries no handoff, but it + # still advances the head. Without recording it the lineage would + # stay permanently behind and exact-head resolution would refuse + # every reviewer. This records only what happened: the same lane + # the record already names as current writer, continuing from the + # head that record left behind. + lineage_result = lane_handoff.record_continuation( + args.handoff_state_dir, + repo=args.repo, + pr_number=after.number, + branch=after.branch, + lane=args.lane, + expected_head=before.head_sha, + resulting_head=after.head_sha, + delivered=outcome.delivered, + ) + event = None if args.lane and args.repo: event = build_delivery_outcome_event( @@ -1349,12 +1795,17 @@ def _classify_main(args: argparse.Namespace) -> int: write_delivery_outcome_event(event, output, force=args.force) if args.json: - print(json.dumps(event or outcome.as_dict(), indent=2, sort_keys=True)) + payload = dict(event or outcome.as_dict()) + if lineage_result is not None: + payload["lineage"] = lineage_result + print(json.dumps(payload, indent=2, sort_keys=True)) else: print( f"delivery {'ok' if outcome.delivered else 'missing'}: " f"transition={outcome.transition} reason={outcome.reason}" ) + if lineage_result is not None: + print(f"lineage {lineage_result['reason']}") return 0 if outcome.delivered else 3 diff --git a/src/code_mower/lane_handoff.py b/src/code_mower/lane_handoff.py index 6623adc2..eb308d32 100644 --- a/src/code_mower/lane_handoff.py +++ b/src/code_mower/lane_handoff.py @@ -20,10 +20,34 @@ from .remote_session import DevinProvider, FakeProvider, RemoteSessions +#: The runner exports this before it writes anything, and every reader has to +#: resolve the same directory or it will silently consult a different store. +STATE_DIR_ENV = "LANE_HANDOFF_STATE_DIR" + + def default_root() -> Path: return (Path.home() / ".local/share/code-mower/lane-handoffs").resolve() +def configured_root(environ: dict | None = None) -> Path: + """The handoff state directory this checkout is actually configured to use. + + The shell runner honours ``LANE_HANDOFF_STATE_DIR`` when it records; a + reader that ignored it would answer from an empty or stale store and could + admit a contributor or refuse an independent reviewer. A configured but + non-absolute value is a misconfiguration rather than a second guess at the + location, so it fails closed instead of falling back to the default. + """ + + value = str((environ if environ is not None else os.environ).get(STATE_DIR_ENV, "")).strip() + if not value: + return default_root() + path = Path(value) + if not path.is_absolute(): + raise LaneDeliveryError(f"{STATE_DIR_ENV} must be an absolute path") + return path.resolve() + + def key(value: object) -> str: return "h" + hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()[:62] @@ -190,6 +214,151 @@ def prepare(handoff: Handoff, source: dict, root: Path, *, "notify": True, "launch_allowed": True, "handoff": handoff.as_dict()} +def lineage_root(root: Path) -> Path: + """Where contribution episodes are recorded, beside the intent store. + + The intent record holds the private source binding; the lineage record must + not, because it is the thing reviewer admission, the label reconciler and + the public projection read. They are separate stores so that a reader of one + never sees the other's fields. + """ + + return Path(root) / "lineage" + + +def record_contribution(handoff: Handoff, root: Path, *, resulting_head: str, + delivered: bool, head: Callable = observe_head) -> dict: + """Persist the ordered episode for a validated delivery. Fails closed. + + This is the only writer of contribution lineage. Every field of the episode + comes from evidence this boundary already verified: repository, PR, branch, + lanes and expected head from the accepted handoff, the source writer state + from the acceptance record rather than the caller, and the resulting head + from a fresh observation checked against what the runner reported. A caller + that merely asserts a takeover, or hands in a ``writer_state`` string of its + own, records nothing. + """ + from .builder_lineage import LineageError, episode_from_handoff, load_episodes, record_episode + + observed = str(resulting_head or "").strip().lower() + if not delivered: + return {"recorded": False, "reason": "delivery_unvalidated"} + identity = key([handoff.target_pr.lower(), handoff.expected_head]) + with ContextStore(root).locked(identity) as locked: + record = locked.read() + if (record is None or record.get("accepted") is not True + or record.get("handoff") != handoff.as_dict()): + return {"recorded": False, "reason": "acceptance_unverified"} + if record.get("launch_reserved") is not True: + return {"recorded": False, "reason": "launch_unreserved"} + writer_state = str(record.get("writer_state") or "") + # The destination lane wrote while this ran, so the head observed here is + # the one the episode binds to. A head the runner reported but GitHub + # does not show is not a resulting head. + if observed != str(head(handoff) or "").strip().lower(): + return {"recorded": False, "reason": "resulting_head_unverified"} + store = lineage_root(root) + repo = handoff.target_pr.split("#")[0] + number = handoff.target_pr.split("#")[1] + try: + sequence = record.get("lineage_sequence") + if not isinstance(sequence, int) or isinstance(sequence, bool): + sequence = len(load_episodes(store, repo, number)) + 1 + episode = episode_from_handoff( + handoff, resulting_head=observed, writer_state=writer_state, + sequence=sequence, repo=repo, + ) + outcome = record_episode(store, episode) + except LineageError as exc: + raise LaneDeliveryError(str(exc)) from None + record["lineage_sequence"] = sequence + locked.write(record) + return {"recorded": outcome["recorded"], "duplicate": outcome["duplicate"], + "reason": "recorded" if outcome["recorded"] else "already_recorded", + "sequence": sequence, "episodes": outcome["episodes"]} + + +def record_continuation(root: Path, *, repo: str, pr_number: object, branch: str, + lane: str, expected_head: str, resulting_head: str, + delivered: bool) -> dict: + """Persist an ordinary same-writer round that advanced an existing lineage. + + After a takeover, the destination lane keeps working: a normal fix round + moves the head with no new handoff to record, and exact-head resolution + would then report ``lineage_behind_head`` for good. Reusing the original + handoff cannot repair that -- its recorded sequence rejects a different + resulting head -- and a self-handoff is invalid by construction. + + So this records the round that actually happened, and only that. It never + manufactures a takeover: it refuses unless the recorded tip already names + ``lane`` as the current writer, unless the round started from exactly the + head that tip left behind, and unless the head genuinely moved. There is no + source writer to quiesce and no launch to reserve, because no other lane is + being displaced -- the writer that went quiescent is this lane's own + supervised round, which the caller has already terminated and reaped. + + ``branch`` is the head branch the caller observed in the same authenticated + read as ``resulting_head``. It is required whenever lineage exists and must + be the branch that lineage already names, so the episode stays bound to one + repository, pull request, branch and head. + """ + from .builder_lineage import ( + CONTINUATION_KIND, LineageError, continuation_episode, load_episodes, record_episode, + ) + + if not delivered: + return {"recorded": False, "reason": "delivery_unvalidated"} + store = lineage_root(root) + try: + episodes = load_episodes(store, repo, pr_number) + except LineageError as exc: + raise LaneDeliveryError(str(exc)) from None + if not episodes: + # No takeover has been recorded, so nothing is behind the head: this is + # the ordinary single-builder case, which needs no episode at all. + return {"recorded": False, "reason": "no_recorded_lineage"} + observed = str(resulting_head or "").strip().lower() + started = str(expected_head or "").strip().lower() + writer = str(lane or "").strip().lower() + target_branch = str(branch or "").strip() + if not target_branch: + # Lineage exists, so this round has to bind to a branch -- and the only + # branch that binds is the one the caller actually observed. Letting an + # absent value fall through to the recorded tip's branch would record + # provenance against a branch nothing verified this round, which is the + # inference the exact-head contract exists to refuse. A snapshot from a + # producer predating the field lands here and refuses, rather than + # having a branch chosen for it. + return {"recorded": False, "reason": "branch_unobserved"} + for episode in episodes: + if (episode.kind == CONTINUATION_KIND and episode.expected_head == started + and episode.resulting_head == observed + and episode.destination_lane == writer): + # Replay of a round already recorded. Idempotent, never a duplicate. + return {"recorded": False, "duplicate": True, "reason": "already_recorded", + "sequence": episode.sequence, "episodes": len(episodes)} + tip = episodes[-1] + if tip.destination_lane != writer: + return {"recorded": False, "reason": "not_current_writer"} + if tip.resulting_head != started: + # Either this round did not start where the lineage stopped, or the + # evidence is stale. Guessing across that gap is what exact-head + # resolution exists to refuse. + return {"recorded": False, "reason": "continuation_unchained"} + if observed == started: + return {"recorded": False, "reason": "head_unchanged"} + try: + episode = continuation_episode( + tip, lane=writer, resulting_head=observed, branch=target_branch, + ) + outcome = record_episode(store, episode) + except LineageError as exc: + raise LaneDeliveryError(str(exc)) from None + return {"recorded": outcome["recorded"], "duplicate": outcome["duplicate"], + "reason": "recorded" if outcome["recorded"] else "already_recorded", + "sequence": episode.sequence, "episodes": outcome["episodes"]} + + def reserve_launch(handoff: Handoff, root: Path, *, head: Callable = observe_head, stop: Callable = quiesce) -> bool: identity = key([handoff.target_pr.lower(), handoff.expected_head]) diff --git a/src/code_mower/lane_status.py b/src/code_mower/lane_status.py index 1f783613..4deb6825 100644 --- a/src/code_mower/lane_status.py +++ b/src/code_mower/lane_status.py @@ -262,6 +262,130 @@ def _author(pr: Mapping[str, Any]) -> str: return _text(author.get("login")) if isinstance(author, Mapping) else _text(author) +def _lineage_comments(pr: Mapping[str, Any]) -> tuple[dict[str, Any], ...]: + """Pull request comments in the shape every other lineage reader expects. + + ``gh`` reports the commenter under ``author``; the shared readers, which + were written against the REST payload, look under ``user``. Normalising + here keeps one marker-trust rule instead of one per transport. + """ + + from .builder_lineage import require_comment_list + + # Validated before it is normalised, like every other authoritative read: + # `or []` turned a null or object response into an empty history, and + # filtering non-mappings out dropped records that could have carried the + # marker. `gh pr view --json comments` embeds one list -- not the gate's + # slurped array of page arrays, and not REST's comment count -- and an + # unreadable one is surfaced to the caller rather than shrunk to absence. + payload = pr.get("comments") + if payload is None: + return () + validated = require_comment_list(payload, what="pull request comments") + return tuple( + { + "user": { + "login": _text( + (item.get("author") or item.get("user") or {}).get("login") + ) + }, + "body": _text(item.get("body")), + } + for item in validated + ) + + +def _lineage_marker_trust(authorities: Sequence[str]): + """The one marker-trust rule, shared with the gate and the labelers.""" + + from .audit_labeler_lib import lineage_marker_author_trust + + return lineage_marker_author_trust(authorities=authorities) + + +def builder_lineage_for( + repo: str, + *, + pr_number: int, + branch: str, + head_sha: str, + labels: Sequence[str], + author: str, + state_dir: Path | None = None, + comments: Sequence[Mapping[str, Any]] = (), + raw_comments: Any = None, +) -> dict[str, Any]: + """Resolve recorded contribution lineage for one pull request at its head. + + Episodes come from the same two places every other reader consults: the + host's *configured* durable record, which only the verified handoff/delivery + boundary writes, and the bounded lineage published on the pull request by a + configured decision authority. Reading the packaged default directory would + consult an empty store on any deployment that configures one, and reading + no comments would make this projection disagree with the gate about the + same pull request. Unreadable evidence resolves to a conflict carrying one + owner action rather than degrading to the label-derived guess this issue + exists to remove. + """ + + from . import builder_lineage as lineage_module + from .decisions import decision_authorities_from_env + from .provider_runners.lineage import load_identity, trusted_episodes + + identity = load_identity() + authorities = decision_authorities_from_env() + try: + # Normalised inside the same guard that already answers for unreadable + # evidence, so a malformed published history reaches the caller as one + # bounded conflict rather than as an exception out of a status run. + if raw_comments is not None: + comments = _lineage_comments({"comments": raw_comments}) + episodes = trusted_episodes( + repo, + pr_number, + comments=comments if authorities else (), + trusted_author=( + _lineage_marker_trust(authorities) if authorities else None + ), + state_dir=state_dir, + ) + except (lineage_module.LineageError, OSError, ValueError): + return lineage_module.Lineage( + status="conflict", + reason="episode_malformed", + head_sha=_text(head_sha), + contributors=(), + current_writer="", + builder_label="", + stale_builder_labels=(), + evidence="handoff_episodes", + episodes=0, + owner_action=( + "recorded builder contribution evidence for this pull request " + "could not be read; re-record it from the verified handoff" + ), + ).as_dict() + opener_lane, label_lanes = lineage_module.lanes_from_identity( + identity=identity, labels=labels, author=author + ) + return lineage_module.resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + # The same identity contract the gate and the wrappers resolve under. + # Without it this projection would call a `codex/` branch labelled + # `builder:claude` a sole Claude writer and route a reviewer on that, + # while the gate refused the very same pull request. + branch_lane=lineage_module.branch_lane_from_identity( + identity=identity, branch=branch + ), + ).as_dict() + + def _summarize_pr( repo: str, pr: Mapping[str, Any], @@ -294,6 +418,15 @@ def _summarize_pr( "next_action": next_action, "next_detail": next_detail, "gate_rerun_command": _gate_rerun_command(repo, number, head_sha), + "builder_lineage": builder_lineage_for( + repo, + pr_number=number, + branch=_text(pr.get("headRefName")), + head_sha=head_sha, + labels=[name for names in labels.values() for name in names], + author=_author(pr), + raw_comments=pr.get("comments") or [], + ), } @@ -342,7 +475,10 @@ def _remote( try: raw_prs = gh_json_runner([ "pr", "list", "--repo", repo, "--state", "open", "--limit", str(pr_limit), - "--json", "number,title,url,headRefName,headRefOid,author,isDraft,mergeStateStatus,updatedAt,labels,statusCheckRollup", + # `comments` carries the published lineage markers, so the Board + # and controller projection resolves the same exact-head evidence + # the gate and the reviewers do rather than the local store alone. + "--json", "number,title,url,headRefName,headRefOid,author,isDraft,mergeStateStatus,updatedAt,labels,statusCheckRollup,comments", ]) except LaneStatusUnavailable as exc: raw_prs = [] diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index d9387afb..f1a67ef1 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -362,6 +362,7 @@ ("tools/blind_review_artifacts.py", "src/code_mower/blind_review_artifacts.py", "core"), ("tools/audit_handoff_log.py", "src/code_mower/audit_handoff_log.py", "core"), ("tools/audit_labeler_lib.py", "src/code_mower/audit_labeler_lib.py", "core"), + ("tools/builder_lineage.py", "src/code_mower/builder_lineage.py", "core"), ("tools/audit_limits.py", "src/code_mower/audit_limits.py", "core"), ("tools/audit_progress.py", "src/code_mower/audit_progress.py", "core"), ( @@ -394,6 +395,11 @@ "src/code_mower/provider_runners/github_pr.py", "reviewer", ), + ( + "src/code_mower/provider_runners/lineage.py", + "src/code_mower/provider_runners/lineage.py", + "reviewer", + ), ( "src/code_mower/provider_runners/process.py", "src/code_mower/provider_runners/process.py", diff --git a/src/code_mower/provider_runners/__init__.py b/src/code_mower/provider_runners/__init__.py index 70ca47d7..6f5d8783 100644 --- a/src/code_mower/provider_runners/__init__.py +++ b/src/code_mower/provider_runners/__init__.py @@ -23,6 +23,14 @@ resolve_github_token_from_env_or_gh, resolve_github_token_from_stdin_or_env, ) +from .lineage import ( + ReviewerNotIndependent, + load_identity, + pr_lineage, + published_episodes, + require_independent_reviewer, + reviewer_admission, +) from .github_pr import ( edit_pr_comment, fetch_issue_comments, @@ -106,6 +114,12 @@ "LOCAL_AUDIT_RUNNER_DOC", "pop_github_token_env", "post_pr_comment", + "pr_lineage", + "published_episodes", + "require_independent_reviewer", + "reviewer_admission", + "ReviewerNotIndependent", + "load_identity", "ProviderWorkspaceError", "repost_audit_verdict_artifact", "require_exact_keys", diff --git a/src/code_mower/provider_runners/github_pr.py b/src/code_mower/provider_runners/github_pr.py index da583566..f7b67631 100644 --- a/src/code_mower/provider_runners/github_pr.py +++ b/src/code_mower/provider_runners/github_pr.py @@ -113,7 +113,16 @@ def fetch_issue_comments( page_cap: int = 10, per_page: int = 100, ) -> list[dict[str, Any]]: - """Return issue/PR comments with a bounded pagination cap.""" + """Return issue/PR comments with a bounded pagination cap. + + Shape is checked before anything else looks at the page. ``None``, ``False`` + and ``{}`` are falsey, so testing emptiness first ended the read and + reported whatever had been gathered as the whole history; filtering members + by ``isinstance`` then dropped malformed entries silently. Both turn "this + could not be read" into "there is nothing here", and the marker that proves + a takeover is in the newest part of the history that gets dropped. Only a + genuinely empty list ends the read. + """ all_comments: list[dict[str, Any]] = [] for page in range(1, page_cap + 1): @@ -122,11 +131,22 @@ def fetch_issue_comments( f"/repos/{repo}/issues/{issue_number}/comments?per_page={per_page}&page={page}", token=token, ) + # The same shared record contract every other authoritative read uses: + # a list of comments whose `body` and `user.login` are readable where + # present. A dict is not a comment -- a numeric body or an object login + # would be stringified into an author or a marker GitHub never sent. + from ..builder_lineage import LineageError, require_comment_list + + try: + require_comment_list( + chunk, + what=f"GitHub issue comments page {page} for {repo}#{issue_number}", + ) + except LineageError as exc: + raise ValueError(str(exc)) from None if not chunk: return all_comments - if not isinstance(chunk, list): - raise ValueError("GitHub issue comments response was not a list") - all_comments.extend(comment for comment in chunk if isinstance(comment, dict)) + all_comments.extend(chunk) if len(chunk) < per_page: return all_comments raise RuntimeError( diff --git a/src/code_mower/provider_runners/lineage.py b/src/code_mower/provider_runners/lineage.py new file mode 100644 index 00000000..ba8a576d --- /dev/null +++ b/src/code_mower/provider_runners/lineage.py @@ -0,0 +1,501 @@ +"""Shared reviewer-independence admission for provider runner wrappers. + +Every direct reviewer wrapper used to answer "may I review this?" on its own +terms: Codex and Claude leaned on the gate's label/author exclusion, and the +Devin wrappers carried a product-specific PR-author deny list. None of those +survive a takeover, where the opener, the branch and the active label can each +name a different lane than the one that wrote the current diff. + +This module is the one admission seam. It runs after the wrapper has fetched +trusted pull request metadata and pinned the exact head, and before any +provider execution, so a lane that contributed to the diff is never spent +reviewing its own work. + +Role eligibility is a separate decision (see :mod:`code_mower.role_eligibility`) +and is deliberately not consulted here: a qualified lane can still be a +contributor, and an independent lane can still be unqualified. +""" + +from __future__ import annotations + +import json +import os +from typing import Any, Callable, Mapping, Sequence + +from ..builder_lineage import ( + Lineage, + LineageError, + episodes_from_comment_body, + lanes_from_identity, + resolve_lineage, +) + + +AUTHOR_EXCLUSION_ENV = "CODE_MOWER_AUTHOR_EXCLUSION_JSON" + + +class ReviewerNotIndependent(RuntimeError): + """Raised when a reviewer lane may not gate the pull request under review. + + ``reason`` and ``owner_action`` are bounded metadata safe to surface in a + public comment; no diagnostic output, path or provider reference is carried. + """ + + def __init__(self, decision: Mapping[str, Any]) -> None: + self.decision = dict(decision) + self.reason = str(decision.get("reason") or "") + self.owner_action = str(decision.get("owner_action") or "") + super().__init__( + f"{decision.get('lane') or 'reviewer'} lane is not admitted: " + f"{self.reason}; {self.owner_action}" + ) + + +def load_identity(raw: str | None = None) -> Mapping[str, Any]: + """Read the existing author-exclusion identity contract. + + A missing or unparsable value disables lane naming rather than inventing + one, which keeps an unconfigured checkout behaving as it does today. + """ + + text = raw if raw is not None else os.environ.get(AUTHOR_EXCLUSION_ENV, "") + if not text: + return {"enabled": False} + try: + parsed = json.loads(text) + except (ValueError, RecursionError): + return {"enabled": False} + return parsed if isinstance(parsed, Mapping) else {"enabled": False} + + +def published_episodes( + comments: Sequence[Mapping[str, Any]], + *, + trusted_author: Callable[[str], bool], +) -> tuple: + """Collect lineage episodes published by already trusted comment authors. + + The hidden marker is a transport for bounded metadata. Trust comes from the + caller's author check, never from the marker being present, so an untrusted + commenter cannot assert a takeover into existence. + """ + + collected: list = [] + for comment in comments: + if not isinstance(comment, Mapping): + continue + login = str(((comment.get("user") or {}).get("login")) or "") + if not login or not trusted_author(login): + continue + collected.extend(episodes_from_comment_body(str(comment.get("body") or ""))) + return tuple(collected) + + +#: Accounts a reviewer lane writes under. Used only as a floor, so an +#: unconfigured or malformed identity file cannot make a contributing reviewer +#: admissible; it never widens who may review. +LANE_ACCOUNT_FLOOR: Mapping[str, tuple[str, ...]] = { + "devin": ( + "devin-ai-integration", + "devin-ai-integration[bot]", + "devin-cli-audit-bot", + "devin-cli-audit-bot[bot]", + ), + "codex": ("chatgpt-codex-connector[bot]", "codex[bot]"), + "claude": ("claude[bot]", "claude-bot"), +} + + +def identity_with_lane_floor(identity: Mapping[str, Any] | None, lane: str) -> Mapping[str, Any]: + """Guarantee the reviewer lane can be named, whatever the configuration says. + + Reviewer independence is decided by naming lanes. A missing, disabled or + malformed identity contract would name none of them, and an unnameable lane + cannot be recognised as a contributor -- which would silently admit exactly + the reviewer this seam exists to exclude. So the lane's own label and + accounts are always present. Only the reviewer's own lane is synthesized: + this adds exclusion and never admission. + """ + + reviewer = str(lane or "").strip().lower() + base = dict(identity) if isinstance(identity, Mapping) else {} + labels = base.get("labels") + authors = base.get("authors") + merged_labels = dict(labels) if isinstance(labels, Mapping) else {} + # Account keys are matched case-insensitively downstream, so the floor has + # to compose over the *same* keys resolution will use. Flooring the raw key + # left `{"Codex[Bot]": "claude"}` untouched beside a new `codex[bot]` + # entry, and which of the two survived normalisation came down to + # insertion order -- an alias could quietly outrank the canonical account + # and let a lane review its own diff. Normalising first also makes two + # aliases that disagree visible as the contradiction they are. + merged_authors = _normalized_account_map( + authors if isinstance(authors, Mapping) else {} + ) + if reviewer: + # A floor, not a default. ``setdefault`` leaves a present-but-useless + # mapping alone -- `{"builder:codex": ""}` keeps naming no lane -- and + # a lane that cannot be named cannot be recognised as a contributor, + # which admits exactly the reviewer this seam exists to exclude. The + # reviewer's own canonical label and accounts therefore *must* resolve + # to its own lane: a blank or malformed entry is overwritten, and one + # that names a different lane is a configuration error the reviewer + # refuses on rather than silently correcting, because the deployment + # believes something about its own identity that is not true. + _claim_own_identity(merged_labels, f"builder:{reviewer}", reviewer, "label") + for login in LANE_ACCOUNT_FLOOR.get(reviewer, ()): + _claim_own_identity( + merged_authors, _account_key(login), reviewer, "account" + ) + # The floor raises the three fields it is responsible for and leaves the + # rest of the deployment's contract intact. Rebuilding the mapping from + # scratch dropped `branch_prefixes` and `require_verified_lineage`, so + # every real wrapper resolved without the configured branch identity it + # was rendered to use -- and a `codex/` branch labelled `builder:claude` + # came back a sole Claude writer, admitting Codex to its own diff. + floored = dict(base) + floored.update( + {"enabled": True, "labels": merged_labels, "authors": merged_authors} + ) + return floored + + +class ReviewerIdentityInvalid(RuntimeError): + """The deployment's identity contract misnames the reviewer's own lane.""" + + +def _account_key(login: Any) -> str: + """The form account lookups actually use: trimmed and case-folded.""" + + return str(login or "").strip().lower() + + +def _normalized_account_map(authors: Mapping[str, Any]) -> dict: + """Account map keyed the way resolution reads it, aliases reconciled. + + Two spellings of one account are compatible when they name the same lane + and a contradiction when they do not -- and a contradiction has to be the + same contradiction whichever order the deployment wrote them in. Values + keep whatever the contract said; only the key is normalised. + """ + + normalized: dict = {} + for raw_key, value in authors.items(): + key = _account_key(raw_key) + if not key: + continue + if key in normalized: + first = str(normalized[key]).strip().lower() + second = str(value).strip().lower() + if first != second: + raise ReviewerIdentityInvalid( + f"reviewer_identity_invalid: the configured accounts name " + f"`{key}` as both `{first or 'nothing'}` and " + f"`{second or 'nothing'}`. Account names are matched " + f"case-insensitively, so these are one account with two " + f"answers; correct CODE_MOWER_AUTHOR_EXCLUSION_JSON." + ) + continue + normalized[key] = value + return normalized + + +def _claim_own_identity( + mapping: dict, key: str, reviewer: str, kind: str +) -> None: + """Make ``key`` name ``reviewer``, or refuse if it already names another.""" + + present = mapping.get(key) + named = str(present).strip().lower() if isinstance(present, str) else "" + if named and named != reviewer: + raise ReviewerIdentityInvalid( + f"reviewer_identity_invalid: the configured {kind} `{key}` names " + f"lane `{named}`, but it is the {reviewer} lane's own {kind}. " + f"Correct CODE_MOWER_AUTHOR_EXCLUSION_JSON before running a " + f"{reviewer} review; a reviewer that cannot name its own lane " + f"cannot be excluded from its own contribution." + ) + mapping[key] = reviewer + + +def recorded_episodes(repo: str, pr_number: Any, state_dir: Any = None) -> tuple: + """Load contribution episodes the verified delivery boundary persisted. + + This is the wrapper-side counterpart of the runner's record, so it must + resolve the *same* directory the runner writes to. When the deployment + configures ``LANE_HANDOFF_STATE_DIR``, reading the packaged default instead + would consult an empty store and miss every verified contribution. + + An unreadable record -- including a configured directory that cannot be + resolved -- raises :class:`~code_mower.builder_lineage.LineageError` so the + caller fails closed; a checkout with no record at all simply has no + episodes, which is the ordinary single-builder case. + """ + + from pathlib import Path + + from ..builder_lineage import LineageError, load_episodes + from ..lane_delivery import LaneDeliveryError + from ..lane_handoff import configured_root, lineage_root + + if state_dir is not None: + root = Path(state_dir) + else: + try: + root = configured_root() + except LaneDeliveryError as exc: + raise LineageError(str(exc)) from None + return load_episodes(lineage_root(root), repo, pr_number) + + +def trusted_episodes( + repo: str, + pr_number: Any, + *, + comments: Sequence[Mapping[str, Any]] = (), + trusted_author: Callable[[str], bool] | None = None, + published: Sequence[Any] = (), + state_dir: Any = None, +) -> tuple: + """All contribution evidence this reviewer is allowed to read, in order. + + The durable record is the runner's own; published markers are the transport + for a reviewer running somewhere the record does not exist -- which is the + ordinary case for an independent reviewer host, whose private store is + empty. Both are parsed strictly and merged by sequence. + + An episode that merely repeats one already collected is dropped, so the + producer republishing the whole chain on every round does not inflate the + input. An episode that *contradicts* one already collected is kept, so the + resolver still sees the duplicate position and fails closed on it. + + ``published`` accepts episodes an embedding caller already established as + trusted, for adapters that carry the transport rather than the comments. + """ + + collected = list(recorded_episodes(repo, pr_number, state_dir)) + incoming: list[Any] = list(published) + if comments and trusted_author is not None: + incoming.extend(published_episodes(comments, trusted_author=trusted_author)) + seen = {episode.sequence: episode.as_dict() for episode in collected} + for episode in incoming: + payload = episode.as_dict() + known = seen.get(episode.sequence) + if known == payload: + continue + collected.append(episode) + if known is None: + seen[episode.sequence] = payload + return tuple(collected) + + +def marker_author_trust(authorities: Sequence[str] = ()) -> Callable[[str], bool]: + """Who a reviewer may read published lineage markers from. + + This is deliberately the *same* rule the gate and the labelers apply: the + repository's configured decision authorities, and nobody else. A lineage + marker is a transport for bounded metadata, so being able to post an audit + comment on a pull request is not being able to assert a takeover of it. An + unconfigured checkout trusts nobody and reads no published evidence, which + leaves it behaving exactly as it does without this seam. + """ + + from ..audit_labeler_lib import lineage_marker_author_trust + + return lineage_marker_author_trust(authorities=authorities) + + +def reviewer_evidence( + repo: str, + pr_number: Any, + *, + authorities: Sequence[str] = (), + fetch_comments: Callable[[], Sequence[Mapping[str, Any]]] | None = None, + state_dir: Any = None, +) -> tuple: + """Every contribution record a reviewer host may read, in order. + + A reviewer usually runs somewhere that never recorded anything: its private + store is empty, and the only evidence of a takeover is what the producer + published on the pull request. Reading the private store alone therefore + answers "no takeover happened" on exactly the hosts where the question + matters, so this reads both. + + Comments are fetched only when the repository names decision authorities; + with none configured there is nobody to trust and the fetch would be spent + on evidence that could not be used. A fetch that fails once authorities + *are* configured raises :class:`~code_mower.builder_lineage.LineageError`, + because silently continuing on the private store would be the empty-store + answer again, now indistinguishable from a real absence of lineage. + """ + + trusted = [str(item).strip() for item in authorities if str(item).strip()] + comments: Sequence[Mapping[str, Any]] = () + if trusted and fetch_comments is not None: + try: + fetched = fetch_comments() + except Exception as exc: # bounded: transport, auth and parse failures alike + raise LineageError( + f"published builder lineage for {repo}#{pr_number} could not be " + f"read: {type(exc).__name__}" + ) from None + # A successful fetch that is not a list of comment objects has not + # answered the question. Filtering it down to what happens to be a + # Mapping -- or treating None or {} as "no comments" -- reports an + # unreadable history as an absent one. + from ..builder_lineage import require_comment_list + + comments = require_comment_list( + fetched, what=f"published builder lineage for {repo}#{pr_number}" + ) + return trusted_episodes( + repo, + pr_number, + comments=comments, + trusted_author=marker_author_trust(trusted) if trusted else None, + state_dir=state_dir, + ) + + +def pr_lineage( + *, + repo: str, + pr_number: int, + pr_meta: Mapping[str, Any], + head_sha: str, + episodes: Sequence[Any] = (), + identity: Mapping[str, Any] | None = None, +) -> Lineage: + """Resolve lineage from trusted pull request metadata at an exact head. + + ``pr_meta`` must be the metadata the wrapper fetched from GitHub itself; + ``head_sha`` must be the head the wrapper pinned. Passing a head the caller + did not verify would make every downstream decision unverified too. + """ + + labels = [ + str(label.get("name") or "") + for label in (pr_meta.get("labels") or []) + if isinstance(label, Mapping) + ] + author = str(((pr_meta.get("user") or {}).get("login")) or "") + branch = str(((pr_meta.get("head") or {}).get("ref")) or "") + from ..builder_lineage import branch_lane_from_identity + + contract = identity if identity is not None else load_identity() + opener_lane, label_lanes = lanes_from_identity( + identity=contract, + labels=labels, + author=author, + ) + return resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + # The wrapper decides admission from the same contract the gate does, + # so a configured branch identity has to reach it here too. + branch_lane=branch_lane_from_identity(identity=contract, branch=branch), + ) + + +def reviewer_admission( + lane: str, + *, + repo: str, + pr_number: int, + pr_meta: Mapping[str, Any], + head_sha: str, + episodes: Sequence[Any] = (), + identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Decide whether ``lane`` may review this exact head. Fails closed.""" + + try: + lineage = pr_lineage( + repo=repo, + pr_number=pr_number, + pr_meta=pr_meta, + head_sha=head_sha, + episodes=episodes, + identity=identity, + ) + except LineageError: + return { + "schema": "code_mower.builderLineage.v1", + "lane": str(lane or "").strip().lower(), + "admitted": False, + "reason": "lineage_unreadable", + "head_sha": str(head_sha or ""), + "contributors": [], + "current_writer": "", + "owner_action": ( + "builder contribution evidence for this pull request could not " + "be read; re-record it from the verified handoff" + ), + } + return lineage.admission(lane) + + +def require_independent_reviewer( + lane: str, + *, + repo: str, + pr_number: int, + pr_meta: Mapping[str, Any], + head_sha: str, + episodes: Sequence[Any] = (), + identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Admit ``lane`` or raise :class:`ReviewerNotIndependent`.""" + + decision = reviewer_admission( + lane, + repo=repo, + pr_number=pr_number, + pr_meta=pr_meta, + head_sha=head_sha, + episodes=episodes, + identity=identity, + ) + if not decision["admitted"]: + raise ReviewerNotIndependent(decision) + return decision + + +def require_reviewer_lane( + lane: str, + repo: str, + pr_number: int, + pr_meta: Mapping[str, Any], + head_sha: str, + *, + episodes: Sequence[Any] = (), + identity: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Wrapper-facing admission that reports refusal as a plain ``RuntimeError``. + + Direct reviewer wrappers already surface ``RuntimeError`` as an operator + message with their normal exit handling, so this keeps the shared decision + from needing per-wrapper exception plumbing. The message is bounded + metadata plus one owner action; no diagnostic output or path is included. + """ + + try: + return require_independent_reviewer( + lane, + repo=repo, + pr_number=pr_number, + pr_meta=pr_meta, + head_sha=head_sha, + episodes=episodes, + identity=identity, + ) + except ReviewerNotIndependent as exc: + raise RuntimeError( + f"{lane} reviewer lane is not admitted for {repo}#{pr_number} at " + f"{str(head_sha)[:12]}: {exc.reason}; {exc.owner_action}" + ) from None diff --git a/src/code_mower/saas_reviewer_labeler.py b/src/code_mower/saas_reviewer_labeler.py index 6df6e3a6..7e824c64 100644 --- a/src/code_mower/saas_reviewer_labeler.py +++ b/src/code_mower/saas_reviewer_labeler.py @@ -14,7 +14,7 @@ import os import sys from pathlib import Path -from typing import Any, Optional, Sequence +from typing import Any, Mapping, Optional, Sequence if __package__ and __package__.startswith("code_mower"): from .adapters import load_adapter @@ -23,11 +23,19 @@ GitHubToken, LabelDecision, GitHubRequestError, + LineageContext, + LineageError, apply_label_decision, author_exclusion_reason, fetch_pull_request, github_request_with_fallback, + lineage_context, + lineage_decision_authorities, + lineage_marker_author_trust, + load_author_exclusion_config, load_json, + require_comment_list, + resolve_builder_lineage, sha_matches, ) else: @@ -38,11 +46,19 @@ GitHubToken, LabelDecision, GitHubRequestError, + LineageContext, + LineageError, apply_label_decision, author_exclusion_reason, fetch_pull_request, github_request_with_fallback, + lineage_context, + lineage_decision_authorities, + lineage_marker_author_trust, + load_author_exclusion_config, load_json, + require_comment_list, + resolve_builder_lineage, sha_matches, ) except ImportError: # pragma: no cover - direct `python tools/foo.py` execution @@ -52,11 +68,19 @@ GitHubToken, LabelDecision, GitHubRequestError, + LineageContext, + LineageError, apply_label_decision, author_exclusion_reason, fetch_pull_request, github_request_with_fallback, + lineage_context, + lineage_decision_authorities, + lineage_marker_author_trust, + load_author_exclusion_config, load_json, + require_comment_list, + resolve_builder_lineage, sha_matches, ) @@ -166,15 +190,34 @@ def fetch_issue_comments( tokens: Sequence[GitHubToken], page_cap: int, ) -> list[dict[str, Any]]: - """Fetch issue/PR comments with a safety cap.""" + """Fetch issue/PR comments with a safety cap. + + A page that comes back successfully but is not a list of comment objects + is not an empty page. ``None``, ``False``, a bare object and a list holding + a non-object were all collapsed to "no more comments", which ends the read + early and reports whatever was gathered so far as the whole history -- and + the marker proving a takeover is in the newest part. A genuinely empty list + is still the ordinary end of the history. + """ + + path = f"/repos/{repo}/issues/{issue_number}/comments" all_comments: list[dict[str, Any]] = [] page = 1 while page <= page_cap: chunk = github_request_with_fallback( "GET", - f"/repos/{repo}/issues/{issue_number}/comments?per_page=100&page={page}", + f"{path}?per_page=100&page={page}", tokens=tokens, - ) or [] + ) + # One shared record contract: the page is a list, every entry is a + # comment, and the fields lineage reads -- `body` and `user.login` -- + # are readable where they are present. + try: + require_comment_list(chunk, what=f"comment page {page}") + except LineageError as exc: + raise GitHubRequestError( + "GET", f"{path}?per_page=100&page={page}", 0, str(exc) + ) from None if not chunk: return all_comments all_comments.extend(chunk) @@ -187,6 +230,95 @@ def fetch_issue_comments( ) +def fetch_lineage_comments( + repo: str, + pr_number: int, + *, + tokens: Sequence[GitHubToken], + adapter: SaaSReviewerAdapter, +) -> Optional[list[dict[str, Any]]]: + """The bounded trusted comment history exact-head lineage is published on. + + Every entry path needs the same three things before it can resolve lineage + rather than identity: the exact current head, the head branch and the + comments the evidence lives on. Only the last one costs a request, and only + when the repository configured decision authorities -- with none there is + nobody whose marker would be read and the fetch would buy nothing, which is + also the ordinary no-lineage behaviour this preserves. + + Read failures are raised, never swallowed: a path that cannot tell a + verified takeover from its absence must stop instead of mutating labels on + identity alone. + """ + + if not lineage_decision_authorities(): + return None + return fetch_issue_comments( + repo, + pr_number, + tokens=tokens, + page_cap=adapter.review_comments_page_cap, + ) + + +def structural_lineage_refusal( + *, + repo: str, + pr_number: int, + branch: str, + head_sha: Optional[str], + comments: Optional[Sequence[Mapping[str, Any]]], + labels: Sequence[str], + author: str, +) -> str: + """Why a structural requeue must not touch labels, or ``""`` when it may. + + A requeue removes the done and blocked labels. That is a label-state + mutation, so it answers to the same published-lineage contract a verdict + does: evidence nobody can read, or evidence that disagrees with itself at + this head, is not a basis for changing which lane is believed. Deciding + this *before* either requeue path is the point -- clearing the labels first + and resolving afterwards leaves the pull request already changed. + """ + + try: + lineage = lineage_context( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + comments=comments, + trusted_author=lineage_marker_author_trust( + authorities=lineage_decision_authorities() + ), + ) + except LineageError: + return "published builder lineage is unreadable; leaving labels unchanged" + resolved = resolve_builder_lineage( + labels=list(labels), + author=author, + config=load_author_exclusion_config(), + repo=lineage.repo, + pr_number=lineage.pr_number, + branch=lineage.branch, + head_sha=lineage.head_sha, + episodes=lineage.episodes, + ) + if lineage.episodes and resolved.status != "resolved": + # Only a *published history* blocks the requeue, and only when it does + # not settle at this head: a chain that conflicts, or that describes + # some other head, is not a basis for deciding which lane is believed. + # An identity-only disagreement -- an opener and a label naming + # different lanes, with no episodes at all -- is the ordinary + # no-lineage case every other route already handles, and refusing it + # here would stop requeues that have nothing to do with lineage. + return ( + f"builder lineage is unresolved ({resolved.reason}); " + f"leaving labels unchanged" + ) + return "" + + def has_same_head_review( reviews: list[dict[str, Any]], *, @@ -216,8 +348,32 @@ def resolve_label_decision( current_head_sha: Optional[str] = None, review_comments: Optional[list[dict[str, Any]]] = None, same_head_review_exists: bool = False, + repo: str = "", + head_branch: str = "", + issue_comments: Optional[Sequence[Mapping[str, Any]]] = None, + decision_authorities: Sequence[str] = (), ) -> tuple[Optional[LabelDecision], str]: event_type = event_type or adapter.event_type + # Exact-head lineage is resolved once, from trusted inputs this process + # fetched itself, and handed to whichever entry path decides. Without it + # every SaaS reviewer would fall back to identity-only resolution and treat + # a reconciled takeover as a conflict, skipping its own done-label update. + number = pr_number or int( + (event.get("issue") or {}).get("number") + or (event.get("pull_request") or {}).get("number") + or 0 + ) + try: + lineage = lineage_context( + repo=repo, + pr_number=number, + branch=head_branch, + head_sha=current_head_sha, + comments=issue_comments, + trusted_author=lineage_marker_author_trust(authorities=decision_authorities), + ) + except LineageError: + return None, "published builder lineage is unreadable; skipping label update" if event_type == "pull_request_review": return _resolve_pull_request_review( event, @@ -227,6 +383,7 @@ def resolve_label_decision( pr_body=pr_body, current_head_sha=current_head_sha, review_comments=review_comments or [], + lineage=lineage, ) if event_type == "issue_comment": return _resolve_issue_comment( @@ -235,6 +392,7 @@ def resolve_label_decision( pr_labels=pr_labels or [], pr_author=pr_author, pr_body=pr_body, + lineage=lineage, ) if event_type == "check_run": return _resolve_check_run( @@ -246,6 +404,7 @@ def resolve_label_decision( pr_body=pr_body, current_head_sha=current_head_sha, same_head_review_exists=same_head_review_exists, + lineage=lineage, ) return None, f"unsupported adapter event type: {event_type}" @@ -259,6 +418,7 @@ def _resolve_pull_request_review( pr_body: str, current_head_sha: Optional[str], review_comments: list[dict[str, Any]], + lineage: Optional[LineageContext] = None, ) -> tuple[Optional[LabelDecision], str]: if event.get("action") not in ("submitted", "edited"): return None, f"unsupported pull_request_review action: {event.get('action')}" @@ -283,6 +443,7 @@ def _resolve_pull_request_review( labels=pr_labels, author=pr_author, text=pr_body, + lineage=lineage, ) if exclusion: return None, exclusion @@ -320,6 +481,7 @@ def _resolve_issue_comment( pr_labels: list[str], pr_author: str, pr_body: str, + lineage: Optional[LineageContext] = None, ) -> tuple[Optional[LabelDecision], str]: if event.get("action") not in ("created", "edited"): return None, f"unsupported issue_comment action: {event.get('action')}" @@ -349,6 +511,7 @@ def _resolve_issue_comment( labels=pr_labels, author=issue_author, text=issue_body, + lineage=lineage, ) if exclusion: return None, exclusion @@ -374,6 +537,7 @@ def _resolve_check_run( pr_body: str, current_head_sha: Optional[str], same_head_review_exists: bool, + lineage: Optional[LineageContext] = None, ) -> tuple[Optional[LabelDecision], str]: if event.get("action") != "completed": return None, f"unsupported check_run action: {event.get('action')}" @@ -399,6 +563,7 @@ def _resolve_check_run( labels=pr_labels, author=pr_author, text=pr_body, + lineage=lineage, ) if exclusion: return None, exclusion @@ -605,6 +770,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_labels: list[str] = [] current_head_sha = os.environ.get("DRY_RUN_HEAD_SHA") + head_branch = os.environ.get("DRY_RUN_HEAD_BRANCH", "") + lineage_comments: Optional[Sequence[Mapping[str, Any]]] = None review_comments: list[dict[str, Any]] = [] same_head_review_exists = False pr_number = _event_pr_number(event, adapter, event_type) @@ -685,8 +852,19 @@ def main(argv: Optional[Sequence[str]] = None) -> int: label.get("name", "") for label in pr_current.get("labels") or [] ] candidate_head = pr_current.get("head", {}).get("sha") + candidate_branch = str((pr_current.get("head") or {}).get("ref") or "") candidate_author = str(((pr_current.get("user") or {}).get("login") or "")) candidate_body = str(pr_current.get("body") or "") + try: + candidate_comments = fetch_lineage_comments( + repo, candidate_number, tokens=tokens, adapter=adapter + ) + except (GitHubRequestError, ReviewCommentsTruncated) as exc: + print( + f"skip: could not fetch published builder lineage for " + f"PR #{candidate_number}: {exc}" + ) + continue if adapter.opt_in_required and not adapter.is_opted_in(candidate_labels): _, reason = resolve_label_decision( event, @@ -697,6 +875,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_author=candidate_author, pr_body=candidate_body, current_head_sha=candidate_head, + repo=repo, + head_branch=candidate_branch, + issue_comments=candidate_comments, + decision_authorities=lineage_decision_authorities(), ) print(f"skip: {reason}") continue @@ -710,6 +892,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_author=candidate_author, pr_body=candidate_body, current_head_sha=candidate_head, + repo=repo, + head_branch=candidate_branch, + issue_comments=candidate_comments, + decision_authorities=lineage_decision_authorities(), ) if decision is None: print(f"skip: {reason}") @@ -753,7 +939,34 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_author = str(((pr_current.get("user") or {}).get("login") or "")) pr_body = str(pr_current.get("body") or "") current_head_sha = pr_current.get("head", {}).get("sha") + head_branch = str((pr_current.get("head") or {}).get("ref") or "") + # The review path decides the same question about the same head as the + # issue-comment path, so it reads the same published lineage under the + # same trust rule. + try: + lineage_comments = fetch_lineage_comments( + repo, pr_number, tokens=tokens, adapter=adapter + ) + except (GitHubRequestError, ReviewCommentsTruncated) as exc: + print(f"skip: could not fetch published builder lineage: {exc}") + return 0 if adapter.requires_review_comments: + # Both requeue paths below mutate labels without consulting a + # verdict, so lineage is parsed and admitted here -- once, before + # either of them can clear done or blocked on evidence nobody + # could read. + structural_refusal = structural_lineage_refusal( + repo=repo, + pr_number=pr_number, + branch=head_branch, + head_sha=current_head_sha, + comments=lineage_comments, + labels=pr_labels, + author=pr_author, + ) + if structural_refusal: + print(f"skip: {structural_refusal}") + return 0 review = event.get("review") or {} review_id = review.get("id") if not review_id: @@ -788,7 +1001,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return 0 _apply_or_log(repo, decision, tokens=tokens, lane_name=adapter.name) return 0 - elif event_type == "issue_comment" and pr_number and adapter.opt_in_required: + elif event_type == "issue_comment" and pr_number: issue = event.get("issue") or {} comment = event.get("comment") or {} author = (comment.get("user") or {}).get("login", "") @@ -797,6 +1010,20 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_labels = [label.get("name", "") for label in pr_current.get("labels") or []] pr_author = str(((pr_current.get("user") or {}).get("login") or "")) pr_body = str(pr_current.get("body") or "") + # The comment event carries the issue, never the pull request, so + # the head and branch this decision is about come from the same + # authenticated read that supplied the labels -- not from the + # payload, and not left unset, which resolved identity-only lineage + # and skipped an otherwise eligible independent reviewer's update. + current_head_sha = pr_current.get("head", {}).get("sha") + head_branch = str((pr_current.get("head") or {}).get("ref") or "") + try: + lineage_comments = fetch_lineage_comments( + repo, pr_number, tokens=tokens, adapter=adapter + ) + except (GitHubRequestError, ReviewCommentsTruncated) as exc: + print(f"skip: could not fetch published builder lineage: {exc}") + return 0 elif event_type == "issues" and pr_number and adapter.event_type == "issue_comment": if event.get("action") != "labeled": print(f"skip: unsupported issues action: {event.get('action')}") @@ -829,6 +1056,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int: except (GitHubRequestError, ReviewCommentsTruncated) as exc: print(f"skip: could not fetch issue comments: {exc}") return 0 + # Replay decides about the head the pull request has now, so it uses + # the head from the same authenticated read as the labels rather than + # the dry-run override, which is unset on this path and left every + # replayed comment resolving identity-only lineage. + replay_head = pr_current.get("head", {}).get("sha") or current_head_sha + replay_branch = str((pr_current.get("head") or {}).get("ref") or "") for comment in reversed(comments): synthetic_event = { "action": "created", @@ -843,7 +1076,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int: pr_labels=pr_labels, pr_author=pr_author, pr_body=pr_body, - current_head_sha=current_head_sha, + current_head_sha=replay_head, + repo=repo, + head_branch=replay_branch, + issue_comments=comments, + decision_authorities=lineage_decision_authorities(), ) if decision is None: continue @@ -863,6 +1100,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int: current_head_sha=current_head_sha, review_comments=review_comments, same_head_review_exists=same_head_review_exists, + repo=repo, + head_branch=head_branch, + issue_comments=lineage_comments, + decision_authorities=lineage_decision_authorities(), ) if decision is None: print(f"skip: {reason}") diff --git a/src/code_mower/templates/lanes/run_mac_lane.sh b/src/code_mower/templates/lanes/run_mac_lane.sh index 255ceff2..6b7ecdb9 100644 --- a/src/code_mower/templates/lanes/run_mac_lane.sh +++ b/src/code_mower/templates/lanes/run_mac_lane.sh @@ -949,6 +949,11 @@ snapshot_lookup() { # from "no PR yet" or "no head yet", so a transient failure on one side of the # comparison would fabricate a pr_opened or head_advanced transition for a # target that never moved. +# +# The head branch comes out of the same authenticated pull request read as the +# head sha, so the two describe one observation. Contribution lineage binds an +# episode to repository, PR, branch and head together; re-resolving the branch +# from anywhere else later would bind it to something this snapshot never saw. capture_target_state() { local out="$1" local runner_comment_id="${2:-}" @@ -971,7 +976,7 @@ capture_target_state() { fi if [ -n "$pr_number" ]; then if ! pr_json="$(snapshot_lookup gh pr view "$pr_number" -R "$REPO" \ - --json headRefOid,state,labels 2>/dev/null)"; then + --json headRefName,headRefOid,state,labels 2>/dev/null)"; then pr_json='{}' complete=false fi @@ -993,6 +998,7 @@ capture_target_state() { number: $number, pr_number: $pr, head_sha: ((.headRefOid // "") | ascii_downcase), + branch: (.headRefName // ""), pr_state: (.state // ""), labels: $labels, runner_comment_id: $comment, @@ -1565,11 +1571,55 @@ if [ "${#lane_delivery[@]}" -gt 0 ] && [ "$mode" != "audit" ]; then --output "${log%.log}.delivery.json" --force ) + # A delivered handoff round records its ordered contribution episode against + # the accepted private intent. The episode is written from the acceptance + # record and a fresh head observation, never from anything declared here, so + # a runner that merely names a handoff records nothing. + # The state directory travels even without a handoff: an ordinary fix round + # after a takeover has no new handoff to record but still advances the head, + # and lineage that stops short of it refuses every reviewer. + classify_args+=(--handoff-state-dir "$HANDOFF_STATE_DIR") [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file") set +e "${lane_delivery[@]}" "${classify_args[@]}" delivery_rc=$? set -e + # Publish the verified episodes, then reconcile to exactly one active builder + # label from the verified current writer. Publication comes first and the + # label move is abandoned without it: the GitHub gate reads episodes only + # from trusted comments, so a moved label with no published evidence is the + # conflict this whole path exists to prevent. Historical contributions stay + # in the record; the label only says who may write next. Unresolved lineage + # publishes nothing and changes no label. + if [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then + reconcile_head="$(jq -r '.head_sha // ""' "$after_state" 2>/dev/null || printf '')" + reconcile_branch="$(jq -r '.branch // ""' "$after_state" 2>/dev/null || printf '')" + if [ -n "$reconcile_head" ]; then + reconcile_args=( + lineage --repo "$REPO" --pr "$num" --head "$reconcile_head" + --branch "$reconcile_branch" --state-dir "$HANDOFF_STATE_DIR" + --publish --reconcile-labels --json + ) + # The label set handed to reconciliation decides what is *removed*. A + # failed read is not an empty label set: passing none makes the move + # purely additive, so the destination lane's label goes on while the + # source lane's stays, the pull request carries two builder labels, and + # the run reports success. This read is required, and it is required + # before publication -- neither the lineage comment nor the label may be + # written against a label set nobody observed. + if ! reconcile_labels="$(gh pr view "$num" -R "$REPO" \ + --json labels -q '.labels[].name' 2>/dev/null)"; then + echo "${LANE}: refusing to publish builder lineage or reconcile the builder label for ${REPO}#${num} at ${reconcile_head}: the current label set could not be read, and reconciling against an unobserved label set can leave two builder labels on the pull request. Re-run this unit once 'gh pr view --json labels' succeeds for it." >&2 + exit 2 + fi + while IFS= read -r reconcile_label; do + [ -n "$reconcile_label" ] || continue + reconcile_args+=(--label "$reconcile_label") + done <<< "$reconcile_labels" + "${lane_delivery[@]}" "${reconcile_args[@]}" > "${log%.log}.lineage.json" 2>/dev/null \ + || echo "${LANE}: builder label reconciliation did not resolve at ${reconcile_head}" >&2 + fi + fi observed_transition="$(jq -r '.delivery.transition // "unknown"' \ "${log%.log}.delivery.json" 2>/dev/null || printf 'unknown')" delivery_reason="$(jq -r '.delivery.reason // "unknown"' \ diff --git a/src/code_mower/templates/workflows/builder-provenance.yml.j2 b/src/code_mower/templates/workflows/builder-provenance.yml.j2 index fb380bad..52aac790 100644 --- a/src/code_mower/templates/workflows/builder-provenance.yml.j2 +++ b/src/code_mower/templates/workflows/builder-provenance.yml.j2 @@ -1,14 +1,49 @@ name: Code Mower Builder Provenance on: - pull_request: + # Base-controlled, deliberately. The decision-authority list this job trusts + # is rendered into this file, so the file itself is part of the trust + # boundary: under `pull_request` GitHub runs the workflow *as it exists on + # the proposed revision*, and a contributor could edit their own copy of this + # workflow and name themselves an authority. `pull_request_target` runs the + # definition from the base branch, in the base context -- the reviewed copy, + # and the reviewed authority list with it. + # + # The usual hazard of `pull_request_target` is running untrusted code with a + # privileged token. That does not arise here, and must not be introduced: + # this job never checks the pull request out, never installs its + # dependencies, and never reads its configuration. It installs a pinned + # release of Code Mower and reads GitHub's own metadata for the bound pull + # request. Permissions stay read-only for the same reason -- nothing here + # needs to write, and a base-context token that could would be worth + # attacking. Keep it that way if this workflow is ever extended. + # + # https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target + pull_request_target: types: [opened, edited, synchronize, reopened, ready_for_review] permissions: contents: read + # Read-only, for the published builder lineage the auto-record reads below. + pull-requests: read env: CODE_MOWER_PACKAGE_SPEC: code-mower==1.4.0 + # The same configured decision-authority contract every other Code Mower + # consumer reads. `builder auto-record` trusts published lineage markers from + # these accounts and nobody else, so a job that supplies neither field trusts + # nobody, discards every comment fetched below, and records a taken-over pull + # request against whoever opened it instead of its verified current writer. + # + # The list is rendered here, from the configuration this repository reviewed + # and installed, exactly as the gate and the labelers receive it -- and, + # because of the event above, from the base copy of this file rather than the + # proposed one. Nothing is read out of the pull request's tree: not its + # configuration, and not its copy of this workflow. The repository variable, + # which is a repository setting and not part of any revision, still overrides + # it. + CODE_MOWER_DECISION_AUTHORITIES: __DECISION_AUTHORITIES__ + CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE: {% raw %}${{ vars.CODE_MOWER_DECISION_AUTHORITIES || '' }}{% endraw %} jobs: auto-record: @@ -29,13 +64,132 @@ jobs: - name: Record inferred builder provenance id: record shell: bash + env: + GH_TOKEN: {% raw %}${{ github.token }}{% endraw %} + # Carried as an environment value rather than expanded into the + # script, and checked before use: event data names the bound target, + # it never becomes part of a command. + CODE_MOWER_PR_NUMBER: {% raw %}${{ github.event.pull_request.number }}{% endraw %} run: | set -euo pipefail mkdir -p .code-mower/builder-runs - output=".code-mower/builder-runs/pr-{% raw %}${{ github.event.pull_request.number }}{% endraw %}.cloud-event.json" + number="${CODE_MOWER_PR_NUMBER}" + case "${number}" in + ''|*[!0-9]*) + echo "builder provenance: the event named no numeric pull request;" \ + "refusing to attribute" >&2 + exit 1 + ;; + esac + output=".code-mower/builder-runs/pr-${number}.cloud-event.json" + comments=".code-mower/pr-comments.json" + # The event payload carries the opener, the head and the labels, but + # never the comments the verified lineage is published on. Without + # them a taken-over pull request records a run against whoever opened + # it. Markers are still only read from configured decision + # authorities, so this fetch grants no authority of its own. + # + # The whole history is required, not the first page of it. The marker + # that proves a takeover is posted when the takeover happens, so it is + # among the newest comments on a long-running pull request: reading + # one page, or substituting an empty list for a failed read, silently + # re-attributes the run to whoever opened it. So every page is asked + # for explicitly, the number of requests is finite before the first + # one is made, and every failure stops the job before any attribution + # artifact exists. + python - "${comments}" "${number}" <<'PY' + import json + import os + import subprocess + import sys + + # Finite before the first request: at most MAX_PAGES pages of history + # plus one probe. Reading until the server stops answering is what + # "bounded" exists to rule out. + PER_PAGE = 100 + MAX_PAGES = 20 + MAX_COMMENTS = PER_PAGE * MAX_PAGES + + destination, number = sys.argv[1], sys.argv[2] + repo = os.environ["GITHUB_REPOSITORY"] + + def refuse(reason): + raise SystemExit(f"builder provenance: {reason}; refusing to attribute") + + def read_page(index): + completed = subprocess.run( + [ + "gh", + "api", + f"repos/{repo}/issues/{number}/comments" + f"?per_page={PER_PAGE}&page={index}", + ], + check=False, + stdout=subprocess.PIPE, + text=True, + ) + if completed.returncode != 0: + refuse(f"page {index} of the comment history could not be read") + try: + payload = json.loads(completed.stdout) + except ValueError: + refuse(f"page {index} of the comment history was not complete JSON") + if not isinstance(payload, list): + refuse(f"page {index} of the comment history is not an array") + if len(payload) > PER_PAGE: + refuse(f"page {index} of the comment history is over the page size") + for entry in payload: + # A dict is not a comment. The marker lives in `body` and + # trust is decided from `user.login`, so a present field of + # the wrong type is a record whose meaning cannot be + # recovered -- and coercing it invents an author or a body + # GitHub never sent. `user: null` and an absent `body` are + # GitHub's own schema and stay ordinary. + # Presence and value are different questions: an omitted + # optional field is ordinary, a present one holding null or + # the wrong type is unreadable. The one meaningful null is a + # whole author object, for a deleted account. + if not isinstance(entry, dict): + refuse(f"page {index} of the comment history holds a non-object") + if "body" in entry and not isinstance(entry["body"], str): + refuse(f"page {index} holds a comment whose body is not text") + for field in ("user", "author"): + if field not in entry: + continue + author = entry[field] + if author is None: + continue + if not isinstance(author, dict): + refuse(f"page {index} holds a comment whose author is not an object") + if "login" in author and not isinstance(author["login"], str): + refuse(f"page {index} holds a comment whose author login is not text") + return payload + + comments = [] + index = 1 + while index <= MAX_PAGES: + page = read_page(index) + comments.extend(page) + if len(page) < PER_PAGE: + break + index += 1 + else: + # Exactly MAX_PAGES full pages. One more request settles whether + # the history ends here or runs past the cap -- and an unread + # remainder is precisely where the newest marker would be. + if read_page(MAX_PAGES + 1): + refuse(f"the comment history is longer than {MAX_COMMENTS} comments") + if len(comments) > MAX_COMMENTS: + refuse(f"the comment history is longer than {MAX_COMMENTS} comments") + # One flat array of comment objects: nested page arrays are silently + # ignored by the reader this feeds. + with open(destination, "w", encoding="utf-8") as handle: + json.dump(comments, handle) + PY code-mower builder auto-record \ --pr-json "${GITHUB_EVENT_PATH}" \ --repo "${GITHUB_REPOSITORY}" \ + --comments-json "${comments}" \ --output "${output}" \ --force \ --json | tee .code-mower/builder-auto-record.json diff --git a/src/code_mower/templates/workflows/code-mower-gate.yml.j2 b/src/code_mower/templates/workflows/code-mower-gate.yml.j2 index 278cb089..02b2e039 100644 --- a/src/code_mower/templates/workflows/code-mower-gate.yml.j2 +++ b/src/code_mower/templates/workflows/code-mower-gate.yml.j2 @@ -285,10 +285,13 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) except ImportError: # pragma: no cover - unit-test/package fallback from code_mower.context_review import required_for_checkout @@ -297,12 +300,17 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) + LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" + # github_actions_comment_attested verifies the hidden CODE_MOWER_AUDIT_RUN marker # and requires comment_id/body_sha256 to match the issue comment being evaluated. def emit(state, description): @@ -341,7 +349,26 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ if isinstance(item, dict) } lanes = [lane for lane in lanes if isinstance(lane, dict)] - comments = flatten_paginated_items(comments_payload) + # Comment pages are validated under the shared record contract + # *before* anything flattens, filters or stringifies them. The + # generic flattener drops members it cannot use -- right for a mixed + # timeline, wrong for a comment history, where a dropped comment is a + # dropped marker -- and the `str(... or "")` below would turn a + # present non-string body into plausible text. A history nobody could + # read would otherwise reach the gate looking ordinary. Timeline + # events keep the generic flattener: they are not comments and are + # never read for lineage. + comment_history_readable = True + comment_history_problem = "" + try: + comments = flatten_paginated_comments(comments_payload) + except Exception as exc: + comment_history_readable = False + comment_history_problem = str(exc) + # The gate is already failing on this. Salvaging the readable + # members would hand records nobody could validate to every other + # consumer below, so nothing downstream sees this history at all. + comments = [] events = flatten_paginated_items(events_payload) audit_runs = audit_runs_payload if isinstance(audit_runs_payload, list) else [] configured_decision_authorities = ( @@ -431,16 +458,43 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ context_required=context_required, ) - builder_matches = [] - if isinstance(exclusion, dict) and exclusion.get("enabled"): - label_map = mapping("labels") - for label in labels: - if label in label_map: - builder_matches.append(str(label_map[label])) - author_map = {str(k).lower(): str(v) for k, v in mapping("authors").items()} - if pr_author.lower() in author_map: - builder_matches.append(author_map[pr_author.lower()]) - builder_matches = list(dict.fromkeys(builder_matches)) + # Contribution lineage, not one label and one author. The hidden + # marker is a transport and never an authorization, so episodes are + # read only from the repository's configured decision authorities -- + # the same trust contract the publisher, both labelers and every + # reviewer wrapper apply. Permission to post an audit verdict is not + # takeover authority, so reviewer bot authors are deliberately not + # consulted here. An unconfigured checkout trusts nobody, reads no + # episodes, and falls back to the ordinary single-builder answer. + lineage_authorities = { + item.strip().lower().lstrip("@") + for item in decision_authorities + if item.strip() + } + lineage_episodes = [] + lineage_readable = comment_history_readable + for comment in comments: + comment_body = str(comment.get("body") or "") + if LINEAGE_MARKER not in comment_body: + continue + comment_author = str(((comment.get("user") or {}).get("login")) or "") + if comment_author.strip().lower().lstrip("@") not in lineage_authorities: + continue + try: + lineage_episodes.extend(episodes_from_comment_body(comment_body)) + except Exception: + lineage_readable = False + builder_lineage = resolve_builder_lineage( + labels=sorted(labels), + author=pr_author, + config=exclusion if isinstance(exclusion, dict) else {"enabled": False}, + repo=os.environ.get("GITHUB_REPOSITORY", ""), + pr_number=pr_number, + branch=str(((pr_payload.get("head") or {}).get("ref")) or ""), + head_sha=head_sha, + episodes=lineage_episodes, + ) + builder_matches = list(builder_lineage.contributors) def lane_display(lane): return str(lane.get("display_name") or lane.get("id") or lane.get("done") or "") @@ -530,8 +584,16 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ ) ) - if len(builder_matches) > 1: - emit("failure", "conflicting Code Mower builder identity") + if not lineage_readable: + emit( + "failure", + "Code Mower builder contribution evidence is unreadable" + + (": " + comment_history_problem if comment_history_problem else ""), + ) + elif builder_lineage.status == "conflict": + emit("failure", "conflicting Code Mower builder identity: " + builder_lineage.owner_action) + elif builder_lineage.status == "waiting": + emit("pending", "waiting for builder lineage: " + builder_lineage.owner_action) elif owner_label in labels: emit("pending", owner_label + ": waiting on owner") elif owner_sitting_label and owner_sitting_label in labels: @@ -547,11 +609,13 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ elif blocked: emit("failure", "blocked audit: " + ", ".join(blocked)) else: - excluded_builder = builder_matches[0] if builder_matches else "" + # Every verified contributor to this head is excluded, not just + # the one the active label happens to name. + excluded_builders = set(builder_matches) required = [ lane for lane in lanes - if str(lane.get("author_lane") or lane.get("id") or "") != excluded_builder + if str(lane.get("author_lane") or lane.get("id") or "") not in excluded_builders ] in_flight = in_flight_lanes(required) missing = [ @@ -561,7 +625,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ or latest_current_verdict(lane) != "done" ] if not required: - if excluded_builder: + if excluded_builders: emit("failure", "no independent Code Mower audit lane remains") else: emit("failure", "no Code Mower merge-authority lanes configured") diff --git a/src/code_mower/trailer_comment_labeler.py b/src/code_mower/trailer_comment_labeler.py index 8749e94a..115fee71 100644 --- a/src/code_mower/trailer_comment_labeler.py +++ b/src/code_mower/trailer_comment_labeler.py @@ -23,8 +23,11 @@ GitHubToken, LabelDecision, apply_label_decision, + LineageError, author_exclusion_reason, extract_reviewed_sha, + lineage_context, + lineage_marker_author_trust, fetch_pull_request, fetch_issue_comments, github_actions_comment_attested, @@ -42,8 +45,11 @@ GitHubToken, LabelDecision, apply_label_decision, + LineageError, author_exclusion_reason, extract_reviewed_sha, + lineage_context, + lineage_marker_author_trust, fetch_pull_request, fetch_issue_comments, github_actions_comment_attested, @@ -60,8 +66,11 @@ GitHubToken, LabelDecision, apply_label_decision, + LineageError, author_exclusion_reason, extract_reviewed_sha, + lineage_context, + lineage_marker_author_trust, fetch_pull_request, fetch_issue_comments, github_actions_comment_attested, @@ -235,6 +244,7 @@ def resolve_label_decision( current_head_sha: Optional[str], config: LaneConfig, repo: str = "", + head_branch: str = "", tokens: Sequence[GitHubToken] = (), github_actions_workflows: Sequence[str] = (), actions_run_lookup: Optional[Callable[[str], Mapping[str, Any]]] = None, @@ -298,11 +308,26 @@ def resolve_label_decision( if isinstance(label, dict) and str(label.get("name") or "") ] issue_author = str(((issue.get("user") or {}).get("login") or "")) + # Exact-head lineage, not identity alone. Without this the labeler cannot + # tell a reconciled takeover from a conflict, and an independent reviewer's + # successful verdict on a handed-over PR would never reach its done label. + try: + lineage = lineage_context( + repo=repo, + pr_number=issue_number, + branch=head_branch, + head_sha=current_head_sha, + comments=_comments_with_event_comment(issue_comments or (), comment), + trusted_author=lineage_marker_author_trust(authorities=decision_authorities), + ) + except LineageError: + return None, "published builder lineage is unreadable; skipping label update" exclusion = author_exclusion_reason( lane_name=config.name, labels=issue_labels, author=issue_author, text=str(issue.get("body") or ""), + lineage=lineage, ) if exclusion: return None, exclusion @@ -371,6 +396,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: event = load_json(event_path) current_head_sha = os.environ.get("DRY_RUN_HEAD_SHA") + head_branch = os.environ.get("DRY_RUN_HEAD_BRANCH", "") issue_comments: Sequence[Mapping[str, Any]] | None = None comment_history_complete = True tokens = config.github_tokens_from_env() @@ -382,7 +408,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int: issue = event.get("issue") or {} issue_number = int(issue.get("number", 0)) if issue_number and "pull_request" in issue: - current_head_sha = fetch_pull_request(repo, issue_number, tokens=tokens)["head"]["sha"] + pull_request = fetch_pull_request(repo, issue_number, tokens=tokens) + current_head_sha = pull_request["head"]["sha"] + head_branch = str((pull_request.get("head") or {}).get("ref") or "") page_cap = int(os.environ.get("CODE_MOWER_LABELER_COMMENT_PAGE_CAP", "10")) try: issue_comments = fetch_issue_comments( @@ -397,6 +425,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int: f"warning: {exc}; skipping label update because latest verdict cannot be established", file=sys.stderr, ) + except RuntimeError as exc: + # A successful page that is not a readable comment history is + # the same problem as a truncated one, and is handled the same + # way: the latest verdict cannot be established, so no label + # moves. Reporting it as an absent history would let the run + # label on a history nobody could read. + comment_history_complete = False + print( + f"warning: {exc}; skipping label update because latest verdict cannot be established", + file=sys.stderr, + ) github_actions_workflows = tuple( parse_csv_set(os.environ.get("CODE_MOWER_GITHUB_ACTIONS_WORKFLOWS") or "") @@ -406,6 +445,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: current_head_sha=current_head_sha, config=config, repo=repo, + head_branch=head_branch, tokens=tokens, github_actions_workflows=github_actions_workflows, issue_comments=issue_comments, diff --git a/templates/lanes/run_mac_lane.sh b/templates/lanes/run_mac_lane.sh index 255ceff2..6b7ecdb9 100644 --- a/templates/lanes/run_mac_lane.sh +++ b/templates/lanes/run_mac_lane.sh @@ -949,6 +949,11 @@ snapshot_lookup() { # from "no PR yet" or "no head yet", so a transient failure on one side of the # comparison would fabricate a pr_opened or head_advanced transition for a # target that never moved. +# +# The head branch comes out of the same authenticated pull request read as the +# head sha, so the two describe one observation. Contribution lineage binds an +# episode to repository, PR, branch and head together; re-resolving the branch +# from anywhere else later would bind it to something this snapshot never saw. capture_target_state() { local out="$1" local runner_comment_id="${2:-}" @@ -971,7 +976,7 @@ capture_target_state() { fi if [ -n "$pr_number" ]; then if ! pr_json="$(snapshot_lookup gh pr view "$pr_number" -R "$REPO" \ - --json headRefOid,state,labels 2>/dev/null)"; then + --json headRefName,headRefOid,state,labels 2>/dev/null)"; then pr_json='{}' complete=false fi @@ -993,6 +998,7 @@ capture_target_state() { number: $number, pr_number: $pr, head_sha: ((.headRefOid // "") | ascii_downcase), + branch: (.headRefName // ""), pr_state: (.state // ""), labels: $labels, runner_comment_id: $comment, @@ -1565,11 +1571,55 @@ if [ "${#lane_delivery[@]}" -gt 0 ] && [ "$mode" != "audit" ]; then --output "${log%.log}.delivery.json" --force ) + # A delivered handoff round records its ordered contribution episode against + # the accepted private intent. The episode is written from the acceptance + # record and a fresh head observation, never from anything declared here, so + # a runner that merely names a handoff records nothing. + # The state directory travels even without a handoff: an ordinary fix round + # after a takeover has no new handoff to record but still advances the head, + # and lineage that stops short of it refuses every reviewer. + classify_args+=(--handoff-state-dir "$HANDOFF_STATE_DIR") [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file") set +e "${lane_delivery[@]}" "${classify_args[@]}" delivery_rc=$? set -e + # Publish the verified episodes, then reconcile to exactly one active builder + # label from the verified current writer. Publication comes first and the + # label move is abandoned without it: the GitHub gate reads episodes only + # from trusted comments, so a moved label with no published evidence is the + # conflict this whole path exists to prevent. Historical contributions stay + # in the record; the label only says who may write next. Unresolved lineage + # publishes nothing and changes no label. + if [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then + reconcile_head="$(jq -r '.head_sha // ""' "$after_state" 2>/dev/null || printf '')" + reconcile_branch="$(jq -r '.branch // ""' "$after_state" 2>/dev/null || printf '')" + if [ -n "$reconcile_head" ]; then + reconcile_args=( + lineage --repo "$REPO" --pr "$num" --head "$reconcile_head" + --branch "$reconcile_branch" --state-dir "$HANDOFF_STATE_DIR" + --publish --reconcile-labels --json + ) + # The label set handed to reconciliation decides what is *removed*. A + # failed read is not an empty label set: passing none makes the move + # purely additive, so the destination lane's label goes on while the + # source lane's stays, the pull request carries two builder labels, and + # the run reports success. This read is required, and it is required + # before publication -- neither the lineage comment nor the label may be + # written against a label set nobody observed. + if ! reconcile_labels="$(gh pr view "$num" -R "$REPO" \ + --json labels -q '.labels[].name' 2>/dev/null)"; then + echo "${LANE}: refusing to publish builder lineage or reconcile the builder label for ${REPO}#${num} at ${reconcile_head}: the current label set could not be read, and reconciling against an unobserved label set can leave two builder labels on the pull request. Re-run this unit once 'gh pr view --json labels' succeeds for it." >&2 + exit 2 + fi + while IFS= read -r reconcile_label; do + [ -n "$reconcile_label" ] || continue + reconcile_args+=(--label "$reconcile_label") + done <<< "$reconcile_labels" + "${lane_delivery[@]}" "${reconcile_args[@]}" > "${log%.log}.lineage.json" 2>/dev/null \ + || echo "${LANE}: builder label reconciliation did not resolve at ${reconcile_head}" >&2 + fi + fi observed_transition="$(jq -r '.delivery.transition // "unknown"' \ "${log%.log}.delivery.json" 2>/dev/null || printf 'unknown')" delivery_reason="$(jq -r '.delivery.reason // "unknown"' \ diff --git a/templates/workflows/builder-provenance.yml.j2 b/templates/workflows/builder-provenance.yml.j2 index fb380bad..52aac790 100644 --- a/templates/workflows/builder-provenance.yml.j2 +++ b/templates/workflows/builder-provenance.yml.j2 @@ -1,14 +1,49 @@ name: Code Mower Builder Provenance on: - pull_request: + # Base-controlled, deliberately. The decision-authority list this job trusts + # is rendered into this file, so the file itself is part of the trust + # boundary: under `pull_request` GitHub runs the workflow *as it exists on + # the proposed revision*, and a contributor could edit their own copy of this + # workflow and name themselves an authority. `pull_request_target` runs the + # definition from the base branch, in the base context -- the reviewed copy, + # and the reviewed authority list with it. + # + # The usual hazard of `pull_request_target` is running untrusted code with a + # privileged token. That does not arise here, and must not be introduced: + # this job never checks the pull request out, never installs its + # dependencies, and never reads its configuration. It installs a pinned + # release of Code Mower and reads GitHub's own metadata for the bound pull + # request. Permissions stay read-only for the same reason -- nothing here + # needs to write, and a base-context token that could would be worth + # attacking. Keep it that way if this workflow is ever extended. + # + # https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target + pull_request_target: types: [opened, edited, synchronize, reopened, ready_for_review] permissions: contents: read + # Read-only, for the published builder lineage the auto-record reads below. + pull-requests: read env: CODE_MOWER_PACKAGE_SPEC: code-mower==1.4.0 + # The same configured decision-authority contract every other Code Mower + # consumer reads. `builder auto-record` trusts published lineage markers from + # these accounts and nobody else, so a job that supplies neither field trusts + # nobody, discards every comment fetched below, and records a taken-over pull + # request against whoever opened it instead of its verified current writer. + # + # The list is rendered here, from the configuration this repository reviewed + # and installed, exactly as the gate and the labelers receive it -- and, + # because of the event above, from the base copy of this file rather than the + # proposed one. Nothing is read out of the pull request's tree: not its + # configuration, and not its copy of this workflow. The repository variable, + # which is a repository setting and not part of any revision, still overrides + # it. + CODE_MOWER_DECISION_AUTHORITIES: __DECISION_AUTHORITIES__ + CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE: {% raw %}${{ vars.CODE_MOWER_DECISION_AUTHORITIES || '' }}{% endraw %} jobs: auto-record: @@ -29,13 +64,132 @@ jobs: - name: Record inferred builder provenance id: record shell: bash + env: + GH_TOKEN: {% raw %}${{ github.token }}{% endraw %} + # Carried as an environment value rather than expanded into the + # script, and checked before use: event data names the bound target, + # it never becomes part of a command. + CODE_MOWER_PR_NUMBER: {% raw %}${{ github.event.pull_request.number }}{% endraw %} run: | set -euo pipefail mkdir -p .code-mower/builder-runs - output=".code-mower/builder-runs/pr-{% raw %}${{ github.event.pull_request.number }}{% endraw %}.cloud-event.json" + number="${CODE_MOWER_PR_NUMBER}" + case "${number}" in + ''|*[!0-9]*) + echo "builder provenance: the event named no numeric pull request;" \ + "refusing to attribute" >&2 + exit 1 + ;; + esac + output=".code-mower/builder-runs/pr-${number}.cloud-event.json" + comments=".code-mower/pr-comments.json" + # The event payload carries the opener, the head and the labels, but + # never the comments the verified lineage is published on. Without + # them a taken-over pull request records a run against whoever opened + # it. Markers are still only read from configured decision + # authorities, so this fetch grants no authority of its own. + # + # The whole history is required, not the first page of it. The marker + # that proves a takeover is posted when the takeover happens, so it is + # among the newest comments on a long-running pull request: reading + # one page, or substituting an empty list for a failed read, silently + # re-attributes the run to whoever opened it. So every page is asked + # for explicitly, the number of requests is finite before the first + # one is made, and every failure stops the job before any attribution + # artifact exists. + python - "${comments}" "${number}" <<'PY' + import json + import os + import subprocess + import sys + + # Finite before the first request: at most MAX_PAGES pages of history + # plus one probe. Reading until the server stops answering is what + # "bounded" exists to rule out. + PER_PAGE = 100 + MAX_PAGES = 20 + MAX_COMMENTS = PER_PAGE * MAX_PAGES + + destination, number = sys.argv[1], sys.argv[2] + repo = os.environ["GITHUB_REPOSITORY"] + + def refuse(reason): + raise SystemExit(f"builder provenance: {reason}; refusing to attribute") + + def read_page(index): + completed = subprocess.run( + [ + "gh", + "api", + f"repos/{repo}/issues/{number}/comments" + f"?per_page={PER_PAGE}&page={index}", + ], + check=False, + stdout=subprocess.PIPE, + text=True, + ) + if completed.returncode != 0: + refuse(f"page {index} of the comment history could not be read") + try: + payload = json.loads(completed.stdout) + except ValueError: + refuse(f"page {index} of the comment history was not complete JSON") + if not isinstance(payload, list): + refuse(f"page {index} of the comment history is not an array") + if len(payload) > PER_PAGE: + refuse(f"page {index} of the comment history is over the page size") + for entry in payload: + # A dict is not a comment. The marker lives in `body` and + # trust is decided from `user.login`, so a present field of + # the wrong type is a record whose meaning cannot be + # recovered -- and coercing it invents an author or a body + # GitHub never sent. `user: null` and an absent `body` are + # GitHub's own schema and stay ordinary. + # Presence and value are different questions: an omitted + # optional field is ordinary, a present one holding null or + # the wrong type is unreadable. The one meaningful null is a + # whole author object, for a deleted account. + if not isinstance(entry, dict): + refuse(f"page {index} of the comment history holds a non-object") + if "body" in entry and not isinstance(entry["body"], str): + refuse(f"page {index} holds a comment whose body is not text") + for field in ("user", "author"): + if field not in entry: + continue + author = entry[field] + if author is None: + continue + if not isinstance(author, dict): + refuse(f"page {index} holds a comment whose author is not an object") + if "login" in author and not isinstance(author["login"], str): + refuse(f"page {index} holds a comment whose author login is not text") + return payload + + comments = [] + index = 1 + while index <= MAX_PAGES: + page = read_page(index) + comments.extend(page) + if len(page) < PER_PAGE: + break + index += 1 + else: + # Exactly MAX_PAGES full pages. One more request settles whether + # the history ends here or runs past the cap -- and an unread + # remainder is precisely where the newest marker would be. + if read_page(MAX_PAGES + 1): + refuse(f"the comment history is longer than {MAX_COMMENTS} comments") + if len(comments) > MAX_COMMENTS: + refuse(f"the comment history is longer than {MAX_COMMENTS} comments") + # One flat array of comment objects: nested page arrays are silently + # ignored by the reader this feeds. + with open(destination, "w", encoding="utf-8") as handle: + json.dump(comments, handle) + PY code-mower builder auto-record \ --pr-json "${GITHUB_EVENT_PATH}" \ --repo "${GITHUB_REPOSITORY}" \ + --comments-json "${comments}" \ --output "${output}" \ --force \ --json | tee .code-mower/builder-auto-record.json diff --git a/templates/workflows/code-mower-gate.yml.j2 b/templates/workflows/code-mower-gate.yml.j2 index 278cb089..02b2e039 100644 --- a/templates/workflows/code-mower-gate.yml.j2 +++ b/templates/workflows/code-mower-gate.yml.j2 @@ -285,10 +285,13 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) except ImportError: # pragma: no cover - unit-test/package fallback from code_mower.context_review import required_for_checkout @@ -297,12 +300,17 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ attested_non_current_audit_heads, audit_run_in_flight_detail_for_lane, audit_verdict_newer_than_in_flight, + episodes_from_comment_body, + flatten_paginated_comments, flatten_paginated_items, github_actions_comment_attested, latest_current_audit_verdict, latest_current_audit_verdict_detail, + resolve_builder_lineage, ) + LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" + # github_actions_comment_attested verifies the hidden CODE_MOWER_AUDIT_RUN marker # and requires comment_id/body_sha256 to match the issue comment being evaluated. def emit(state, description): @@ -341,7 +349,26 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ if isinstance(item, dict) } lanes = [lane for lane in lanes if isinstance(lane, dict)] - comments = flatten_paginated_items(comments_payload) + # Comment pages are validated under the shared record contract + # *before* anything flattens, filters or stringifies them. The + # generic flattener drops members it cannot use -- right for a mixed + # timeline, wrong for a comment history, where a dropped comment is a + # dropped marker -- and the `str(... or "")` below would turn a + # present non-string body into plausible text. A history nobody could + # read would otherwise reach the gate looking ordinary. Timeline + # events keep the generic flattener: they are not comments and are + # never read for lineage. + comment_history_readable = True + comment_history_problem = "" + try: + comments = flatten_paginated_comments(comments_payload) + except Exception as exc: + comment_history_readable = False + comment_history_problem = str(exc) + # The gate is already failing on this. Salvaging the readable + # members would hand records nobody could validate to every other + # consumer below, so nothing downstream sees this history at all. + comments = [] events = flatten_paginated_items(events_payload) audit_runs = audit_runs_payload if isinstance(audit_runs_payload, list) else [] configured_decision_authorities = ( @@ -431,16 +458,43 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ context_required=context_required, ) - builder_matches = [] - if isinstance(exclusion, dict) and exclusion.get("enabled"): - label_map = mapping("labels") - for label in labels: - if label in label_map: - builder_matches.append(str(label_map[label])) - author_map = {str(k).lower(): str(v) for k, v in mapping("authors").items()} - if pr_author.lower() in author_map: - builder_matches.append(author_map[pr_author.lower()]) - builder_matches = list(dict.fromkeys(builder_matches)) + # Contribution lineage, not one label and one author. The hidden + # marker is a transport and never an authorization, so episodes are + # read only from the repository's configured decision authorities -- + # the same trust contract the publisher, both labelers and every + # reviewer wrapper apply. Permission to post an audit verdict is not + # takeover authority, so reviewer bot authors are deliberately not + # consulted here. An unconfigured checkout trusts nobody, reads no + # episodes, and falls back to the ordinary single-builder answer. + lineage_authorities = { + item.strip().lower().lstrip("@") + for item in decision_authorities + if item.strip() + } + lineage_episodes = [] + lineage_readable = comment_history_readable + for comment in comments: + comment_body = str(comment.get("body") or "") + if LINEAGE_MARKER not in comment_body: + continue + comment_author = str(((comment.get("user") or {}).get("login")) or "") + if comment_author.strip().lower().lstrip("@") not in lineage_authorities: + continue + try: + lineage_episodes.extend(episodes_from_comment_body(comment_body)) + except Exception: + lineage_readable = False + builder_lineage = resolve_builder_lineage( + labels=sorted(labels), + author=pr_author, + config=exclusion if isinstance(exclusion, dict) else {"enabled": False}, + repo=os.environ.get("GITHUB_REPOSITORY", ""), + pr_number=pr_number, + branch=str(((pr_payload.get("head") or {}).get("ref")) or ""), + head_sha=head_sha, + episodes=lineage_episodes, + ) + builder_matches = list(builder_lineage.contributors) def lane_display(lane): return str(lane.get("display_name") or lane.get("id") or lane.get("done") or "") @@ -530,8 +584,16 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ ) ) - if len(builder_matches) > 1: - emit("failure", "conflicting Code Mower builder identity") + if not lineage_readable: + emit( + "failure", + "Code Mower builder contribution evidence is unreadable" + + (": " + comment_history_problem if comment_history_problem else ""), + ) + elif builder_lineage.status == "conflict": + emit("failure", "conflicting Code Mower builder identity: " + builder_lineage.owner_action) + elif builder_lineage.status == "waiting": + emit("pending", "waiting for builder lineage: " + builder_lineage.owner_action) elif owner_label in labels: emit("pending", owner_label + ": waiting on owner") elif owner_sitting_label and owner_sitting_label in labels: @@ -547,11 +609,13 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ elif blocked: emit("failure", "blocked audit: " + ", ".join(blocked)) else: - excluded_builder = builder_matches[0] if builder_matches else "" + # Every verified contributor to this head is excluded, not just + # the one the active label happens to name. + excluded_builders = set(builder_matches) required = [ lane for lane in lanes - if str(lane.get("author_lane") or lane.get("id") or "") != excluded_builder + if str(lane.get("author_lane") or lane.get("id") or "") not in excluded_builders ] in_flight = in_flight_lanes(required) missing = [ @@ -561,7 +625,7 @@ __GATE_AUTHOR_ENV_ASSIGNMENTS__ or latest_current_verdict(lane) != "done" ] if not required: - if excluded_builder: + if excluded_builders: emit("failure", "no independent Code Mower audit lane remains") else: emit("failure", "no Code Mower merge-authority lanes configured") diff --git a/tests/test_builder_lineage.py b/tests/test_builder_lineage.py new file mode 100644 index 00000000..cc56ff3e --- /dev/null +++ b/tests/test_builder_lineage.py @@ -0,0 +1,382 @@ +"""Exact-head builder contribution lineage regressions. + +The primary fixture is the PR #959 shape: a pull request opened by Devin on a +Devin branch, an explicit verified Codex takeover, and a Codex final head. +""" + +from __future__ import annotations + +import unittest + +from code_mower.builder_lineage import ( + ContributionEpisode, + LineageError, + episodes_from_comment_body, + lanes_from_identity, + lineage_comment_marker, + record_episode, + load_episodes, + resolve_identity_only, + resolve_lineage, +) +from code_mower.provider_runners.lineage import ( + ReviewerNotIndependent, + require_independent_reviewer, + reviewer_admission, +) + + +REPO = "codemower-ai/code-mower" +BRANCH = "devin/959-release-dogfood" +H1 = "a" * 40 +H2 = "b" * 40 +H3 = "c" * 40 + +IDENTITY = { + "enabled": True, + "labels": { + "builder:devin": "devin", + "builder:codex": "codex", + "builder:claude": "claude", + }, + "authors": { + "devin-ai-integration[bot]": "devin", + "chatgpt-codex-connector": "codex", + }, +} + + +def episode(**overrides): + payload = dict( + sequence=1, + repo=REPO, + pr_number=959, + branch=BRANCH, + source_lane="devin", + destination_lane="codex", + expected_head=H1, + resulting_head=H2, + writer_state="terminated", + ) + payload.update(overrides) + return ContributionEpisode(**payload) + + +def resolve(**overrides): + kwargs = dict( + repo=REPO, + pr_number=959, + branch=BRANCH, + head_sha=H2, + episodes=(episode(),), + opener_lane="devin", + label_lanes=("codex",), + ) + kwargs.update(overrides) + return resolve_lineage(**kwargs) + + +class TakeoverLineageTests(unittest.TestCase): + def test_devin_opener_with_verified_codex_takeover(self): + lineage = resolve() + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.contributors, ("devin", "codex")) + self.assertEqual(lineage.current_writer, "codex") + self.assertEqual(lineage.builder_label, "builder:codex") + self.assertEqual(lineage.stale_builder_labels, ()) + # Both contributors are excluded from gating their own work. + self.assertFalse(lineage.independent("devin")) + self.assertFalse(lineage.independent("codex")) + # An uninvolved qualified lane may review the exact final head. + self.assertTrue(lineage.independent("claude")) + self.assertEqual(lineage.independent_lanes(("devin", "codex", "claude")), ("claude",)) + + def test_stale_builder_label_is_reported_not_treated_as_conflict(self): + lineage = resolve(label_lanes=("devin",)) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.current_writer, "codex") + self.assertEqual(lineage.builder_label, "builder:codex") + self.assertEqual(lineage.stale_builder_labels, ("devin",)) + + def test_label_for_an_uninvolved_lane_fails_closed(self): + lineage = resolve(label_lanes=("claude",)) + self.assertEqual(lineage.status, "conflict") + self.assertEqual(lineage.reason, "label_outside_lineage") + self.assertTrue(lineage.owner_action) + self.assertFalse(lineage.independent("claude")) + + def test_head_change_leaves_lineage_waiting_rather_than_guessing(self): + lineage = resolve(head_sha=H3) + self.assertEqual(lineage.status, "waiting") + self.assertEqual(lineage.reason, "lineage_behind_head") + self.assertEqual(lineage.contributors, ()) + self.assertEqual(lineage.current_writer, "") + self.assertFalse(lineage.independent("claude")) + + def test_a_destination_that_never_moved_the_head_is_writer_not_contributor(self): + lineage = resolve( + episodes=(episode(resulting_head=H1),), head_sha=H1, label_lanes=("codex",) + ) + self.assertEqual(lineage.contributors, ("devin",)) + self.assertEqual(lineage.current_writer, "codex") + self.assertTrue(lineage.independent("claude")) + self.assertFalse(lineage.independent("devin")) + + def test_second_takeover_preserves_the_whole_ordered_history(self): + lineage = resolve( + episodes=( + episode(), + episode( + sequence=2, + source_lane="codex", + destination_lane="claude", + expected_head=H2, + resulting_head=H3, + ), + ), + head_sha=H3, + label_lanes=("claude",), + ) + self.assertEqual(lineage.contributors, ("devin", "codex", "claude")) + self.assertEqual(lineage.current_writer, "claude") + self.assertEqual(lineage.independent_lanes(("devin", "codex", "claude")), ()) + + +class AdversarialEvidenceTests(unittest.TestCase): + def test_conflicting_author_and_label_without_a_handoff_fails_closed(self): + lineage = resolve_lineage( + repo=REPO, pr_number=959, branch=BRANCH, head_sha=H2, + episodes=(), opener_lane="devin", label_lanes=("codex",), + ) + self.assertEqual(lineage.status, "conflict") + self.assertEqual(lineage.reason, "conflicting_builder_identity") + self.assertIn("record the handoff", lineage.owner_action) + + def test_normal_single_builder_case_still_resolves(self): + lineage = resolve_lineage( + repo=REPO, pr_number=959, branch="claude/963-lineage", head_sha=H2, + episodes=(), opener_lane="claude", label_lanes=("claude",), + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.contributors, ("claude",)) + self.assertEqual(lineage.current_writer, "claude") + self.assertFalse(lineage.independent("claude")) + self.assertTrue(lineage.independent("codex")) + + def test_a_pull_request_with_no_builder_identity_excludes_nobody(self): + lineage = resolve_lineage( + repo=REPO, pr_number=959, branch="fix/typo", head_sha=H2, + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.contributors, ()) + self.assertTrue(lineage.independent("codex")) + + def test_episode_bound_to_another_repository_pr_or_branch_is_rejected(self): + for overrides in ( + {"repo": "other/repo"}, + {"pr_number": 960}, + {"branch": "codex/959-other"}, + ): + with self.subTest(**overrides): + lineage = resolve(episodes=(episode(**overrides),)) + self.assertEqual(lineage.status, "conflict") + self.assertEqual(lineage.reason, "episode_unbound") + + def test_unchained_reordered_and_duplicated_episodes_fail_closed(self): + broken = resolve( + episodes=( + episode(), + episode(sequence=2, source_lane="codex", destination_lane="claude", + expected_head=H3, resulting_head=H3[:39] + "d"), + ), + head_sha=H3[:39] + "d", + label_lanes=(), + ) + self.assertEqual(broken.reason, "episode_unchained") + + gap = resolve( + episodes=(episode(sequence=2),), head_sha=H2, label_lanes=("codex",) + ) + self.assertEqual(gap.reason, "episode_unchained") + + duplicated = resolve( + episodes=(episode(), episode(destination_lane="claude")), + label_lanes=(), + ) + self.assertEqual(duplicated.reason, "episode_duplicated") + + def test_an_identical_replayed_episode_is_not_a_duplicate(self): + lineage = resolve(episodes=(episode(), episode())) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.episodes, 1) + self.assertEqual(lineage.contributors, ("devin", "codex")) + + def test_malformed_and_unverified_writer_state_fail_closed(self): + malformed = resolve(episodes=({"schema": "nope"},)) + self.assertEqual(malformed.reason, "episode_malformed") + + raw = episode().as_dict() + raw["writer_state"] = "unknown" + unverified = resolve(episodes=(raw,)) + self.assertIn(unverified.reason, {"episode_malformed", "writer_state_unverified"}) + + def test_an_opener_outside_the_lineage_fails_closed(self): + lineage = resolve(opener_lane="claude", label_lanes=()) + self.assertEqual(lineage.reason, "opener_outside_lineage") + + def test_an_abbreviated_or_missing_head_is_never_resolved(self): + for head in ("", "abc1234", H2[:39]): + with self.subTest(head=head): + lineage = resolve(head_sha=head) + self.assertEqual(lineage.status, "conflict") + self.assertEqual(lineage.reason, "target_invalid") + + def test_episode_construction_rejects_self_handoff_and_bad_shas(self): + for overrides in ( + {"destination_lane": "devin"}, + {"expected_head": "zz"}, + {"sequence": 0}, + {"writer_state": "running"}, + ): + with self.subTest(**overrides): + with self.assertRaises(LineageError): + episode(**overrides) + + +class IdentityMappingTests(unittest.TestCase): + def test_identity_maps_labels_and_author_onto_lane_names(self): + opener, labels = lanes_from_identity( + identity=IDENTITY, + labels=["builder:codex", "tier:R"], + author="devin-ai-integration[bot]", + ) + self.assertEqual(opener, "devin") + self.assertEqual(labels, ("codex",)) + + def test_disabled_identity_names_no_lanes(self): + self.assertEqual( + lanes_from_identity(identity={"enabled": False}, labels=["builder:codex"], + author="devin-ai-integration[bot]"), + ("", ()), + ) + + def test_identity_only_resolution_needs_no_head(self): + lineage = resolve_identity_only(opener_lane="claude", label_lanes=("claude",)) + self.assertEqual(lineage.current_writer, "claude") + self.assertEqual( + resolve_identity_only(opener_lane="devin", label_lanes=("codex",)).status, + "conflict", + ) + + +class RecordTests(unittest.TestCase): + def setUp(self): + import tempfile + from pathlib import Path + + # Private lineage state must live outside any Git repository, so the + # record tests need a temporary root that is not inside this checkout. + base = Path(tempfile.gettempdir()).resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + base = Path("/tmp").resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + self.skipTest("no Git-free temporary directory is available here") + self.root = tempfile.mkdtemp(prefix="code-mower-lineage-", dir=str(base)) + self.addCleanup(__import__("shutil").rmtree, self.root, True) + + def test_recording_is_idempotent_and_chained(self): + first = record_episode(self.root, episode()) + self.assertEqual(first, {"recorded": True, "duplicate": False, "episodes": 1}) + replay = record_episode(self.root, episode()) + self.assertEqual(replay, {"recorded": False, "duplicate": True, "episodes": 1}) + self.assertEqual(len(load_episodes(self.root, REPO, 959)), 1) + + with self.assertRaises(LineageError): + record_episode(self.root, episode(destination_lane="claude")) + with self.assertRaises(LineageError): + # Sequence 2 must start from the head sequence 1 produced. + record_episode( + self.root, + episode(sequence=2, source_lane="codex", destination_lane="claude", + expected_head=H3, resulting_head=H1), + ) + record_episode( + self.root, + episode(sequence=2, source_lane="codex", destination_lane="claude", + expected_head=H2, resulting_head=H3), + ) + self.assertEqual(len(load_episodes(self.root, REPO, 959)), 2) + + def test_unrecorded_pull_requests_read_as_empty(self): + self.assertEqual(load_episodes(self.root, REPO, 1), ()) + + +class PublicTransportTests(unittest.TestCase): + def test_marker_round_trips_metadata_only(self): + marker = lineage_comment_marker((episode(),)) + self.assertNotIn("/Users", marker) + self.assertNotIn("session", marker) + parsed = episodes_from_comment_body("text\n" + marker + "\nmore") + self.assertEqual(parsed, (episode(),)) + + def test_an_unreadable_marker_raises_rather_than_being_ignored(self): + with self.assertRaises(LineageError): + episodes_from_comment_body("") + + def test_a_body_without_a_marker_yields_nothing(self): + self.assertEqual(episodes_from_comment_body("Codex took this over."), ()) + + +class ReviewerAdmissionTests(unittest.TestCase): + def pr_meta(self, *, author="devin-ai-integration[bot]", labels=("builder:codex",)): + return { + "user": {"login": author}, + "head": {"ref": BRANCH, "sha": H2}, + "labels": [{"name": name} for name in labels], + } + + def test_contributors_are_refused_and_an_independent_lane_is_admitted(self): + for lane, admitted in (("devin", False), ("codex", False), ("claude", True)): + with self.subTest(lane=lane): + decision = reviewer_admission( + lane, repo=REPO, pr_number=959, pr_meta=self.pr_meta(), + head_sha=H2, episodes=(episode(),), identity=IDENTITY, + ) + self.assertEqual(decision["admitted"], admitted) + self.assertEqual(decision["current_writer"], "codex") + self.assertEqual(decision["contributors"], ["devin", "codex"]) + if not admitted: + self.assertEqual(decision["reason"], "contributor_not_independent") + self.assertTrue(decision["owner_action"]) + + def test_admission_uses_the_head_the_caller_pinned(self): + decision = reviewer_admission( + "claude", repo=REPO, pr_number=959, pr_meta=self.pr_meta(), + head_sha=H3, episodes=(episode(),), identity=IDENTITY, + ) + self.assertFalse(decision["admitted"]) + self.assertEqual(decision["reason"], "lineage_waiting") + + def test_require_independent_reviewer_raises_with_bounded_metadata(self): + with self.assertRaises(ReviewerNotIndependent) as caught: + require_independent_reviewer( + "codex", repo=REPO, pr_number=959, pr_meta=self.pr_meta(), + head_sha=H2, episodes=(episode(),), identity=IDENTITY, + ) + message = str(caught.exception) + self.assertIn("contributor_not_independent", message) + self.assertNotIn("/", message.split("is not admitted")[0]) + + def test_no_evidence_plus_contradictory_signals_refuses_every_lane(self): + for lane in ("devin", "codex", "claude"): + with self.subTest(lane=lane): + decision = reviewer_admission( + lane, repo=REPO, pr_number=959, pr_meta=self.pr_meta(), + head_sha=H2, identity=IDENTITY, + ) + self.assertFalse(decision["admitted"]) + self.assertEqual(decision["reason"], "lineage_conflict") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_builder_lineage_consumers.py b/tests/test_builder_lineage_consumers.py new file mode 100644 index 00000000..059a0233 --- /dev/null +++ b/tests/test_builder_lineage_consumers.py @@ -0,0 +1,1103 @@ +"""Consumer-level regressions for exact-head builder lineage. + +These exercise the production seams the resolver's own unit tests cannot: the +generated product gate's dependency set, the runner's publish-then-reconcile +ordering, the trailer/SaaS labeler callers, continuation recording after a +takeover, and the configured handoff directory. Each one is written from the +#959 shape -- a Devin-opened PR taken over by Codex, audited by an independent +Claude -- because that is the case every single-signal answer got wrong. +""" + +from __future__ import annotations + +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from code_mower import builder_lineage, init, lane_delivery, lane_handoff # noqa: E402 +from code_mower.audit_labeler_lib import ( # noqa: E402 + NO_LINEAGE, + author_exclusion_reason, + builder_identity_matches, + lineage_context, + lineage_marker_author_trust, +) +from code_mower.provider_runners import lineage as reviewer_lineage # noqa: E402 + +REPO = "codemower-ai/code-mower" +PR = 959 +BRANCH = "devin/959-thing" +OPENED = "a" * 40 +TAKEN = "b" * 40 +FIXED = "c" * 40 + +IDENTITY = { + "enabled": True, + "labels": {"builder:devin": "devin", "builder:codex": "codex", "builder:claude": "claude"}, + "authors": {"devin-ai-integration[bot]": "devin", "codex[bot]": "codex"}, +} + + +def git_free_tempdir(case: unittest.TestCase, prefix: str = "code-mower-lineage-") -> Path: + """A private store root outside any checkout, as the context store demands.""" + + base = Path(tempfile.gettempdir()).resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + base = Path("/tmp").resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + case.skipTest("no Git-free temporary directory is available here") + path = Path(tempfile.mkdtemp(prefix=prefix, dir=str(base))).resolve() + case.addCleanup(__import__("shutil").rmtree, path, True) + return path + + +def takeover_episode(sequence: int = 1, resulting: str = TAKEN) -> builder_lineage.ContributionEpisode: + """The verified Devin -> Codex takeover that produced the current head.""" + + return builder_lineage.ContributionEpisode( + sequence=sequence, + kind=builder_lineage.HANDOFF_KIND, + repo=REPO, + pr_number=PR, + branch=BRANCH, + source_lane="devin", + destination_lane="codex", + expected_head=OPENED, + resulting_head=resulting, + writer_state="terminated", + ) + + +class GeneratedProductSupportFiles(unittest.TestCase): + """codex:5f53e76584997c80c318 -- the gate helper's dependency must travel.""" + + def test_builder_lineage_is_a_generated_product_support_file(self): + targets = {target for target, _, _, _ in init.PRODUCT_SUPPORT_FILES} + self.assertIn("tools/audit_labeler_lib.py", targets) + self.assertIn("tools/builder_lineage.py", targets) + + def test_every_import_of_the_helper_is_copied_alongside_it(self): + """Whatever audit_labeler_lib imports has to be in the same list.""" + + sources = { + target: package_copy_from + for target, package_copy_from, _, _ in init.PRODUCT_SUPPORT_FILES + if target.startswith("tools/") and target.endswith(".py") + } + helper = Path("src/code_mower") / sources["tools/audit_labeler_lib.py"] + text = helper.read_text(encoding="utf-8") + for module in ("builder_lineage", "decisions", "context_review"): + self.assertTrue( + f"from {module} import" in text or f"import {module} as" in text, + f"{module} is not imported by the helper", + ) + self.assertIn(f"tools/{module}.py", sources) + + def test_generated_gate_imports_its_helper_without_the_package(self): + """A product gate runner has no code_mower installed. Prove it works.""" + + if True: + tmp = git_free_tempdir(self) + root = Path(tmp) + tools = root / "tools" + tools.mkdir() + (tools / "__init__.py").write_text("", encoding="utf-8") + for target, package_copy_from, _, _ in init.PRODUCT_SUPPORT_FILES: + if not (target.startswith("tools/") and target.endswith(".py")): + continue + source = Path("src/code_mower") / package_copy_from + if not source.exists(): # templated wrappers, not package modules + continue + (root / target).write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + # Exactly what a generated gate step does: run a script from the + # product repository root, with no Code Mower package anywhere on + # the path, and import the helper plus its dependency. + (root / "probe.py").write_text( + "import tools.audit_labeler_lib as lib\n" + "print(lib.builder_identity_matches(" + "labels=['builder:codex'], author='codex[bot]', text=''," + " config={'enabled': True, 'labels': {'builder:codex': 'codex'}," + " 'authors': {'codex[bot]': 'codex'}}))\n", + encoding="utf-8", + ) + environment = { + key: value + for key, value in os.environ.items() + if key not in {"PYTHONPATH", "PYTHONHOME"} + } + result = subprocess.run( + [sys.executable, "probe.py"], + cwd=root, capture_output=True, text=True, timeout=120, env=environment, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("codex", result.stdout) + + +class ConfiguredHandoffDirectory(unittest.TestCase): + """codex:69acb7733dd8b7ba1b31 -- one store for recording and admission.""" + + def test_reviewer_resolves_the_configured_directory(self): + if True: + tmp = git_free_tempdir(self) + configured = tmp / "configured" + builder_lineage.record_episode( + lane_handoff.lineage_root(configured), takeover_episode() + ) + with mock.patch.dict( + os.environ, {lane_handoff.STATE_DIR_ENV: str(configured)}, clear=False + ): + episodes = reviewer_lineage.recorded_episodes(REPO, PR) + self.assertEqual(len(episodes), 1) + self.assertEqual(episodes[0].destination_lane, "codex") + + def test_unconfigured_reviewer_still_reads_the_default_root(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop(lane_handoff.STATE_DIR_ENV, None) + self.assertEqual(lane_handoff.configured_root(), lane_handoff.default_root()) + + def test_a_relative_configured_directory_fails_closed(self): + with mock.patch.dict( + os.environ, {lane_handoff.STATE_DIR_ENV: "relative/store"}, clear=False + ): + with self.assertRaises(builder_lineage.LineageError): + reviewer_lineage.recorded_episodes(REPO, PR) + + def test_recording_and_admission_agree_on_the_configured_store(self): + """The bug: the reviewer read an empty default and admitted a contributor.""" + + if True: + tmp = git_free_tempdir(self) + configured = tmp / "configured" + builder_lineage.record_episode( + lane_handoff.lineage_root(configured), takeover_episode() + ) + pr_meta = { + "labels": [{"name": "builder:codex"}], + "user": {"login": "devin-ai-integration[bot]"}, + "head": {"ref": BRANCH}, + } + with mock.patch.dict( + os.environ, {lane_handoff.STATE_DIR_ENV: str(configured)}, clear=False + ): + episodes = reviewer_lineage.recorded_episodes(REPO, PR) + for lane, admitted in (("codex", False), ("devin", False), ("claude", True)): + decision = reviewer_lineage.reviewer_admission( + lane, repo=REPO, pr_number=PR, pr_meta=pr_meta, head_sha=TAKEN, + episodes=episodes, identity=IDENTITY, + ) + self.assertEqual(decision["admitted"], admitted, lane) + + +class ContinuationDeliveries(unittest.TestCase): + """codex:7b1f8c5e122de3ff7727 -- an ordinary fix round after a takeover.""" + + def setUp(self): + self.root = git_free_tempdir(self) / "handoffs" + builder_lineage.record_episode(lane_handoff.lineage_root(self.root), takeover_episode()) + + def record(self, **overrides): + payload = dict( + repo=REPO, pr_number=PR, branch=BRANCH, lane="codex", + expected_head=TAKEN, resulting_head=FIXED, delivered=True, + ) + payload.update(overrides) + return lane_handoff.record_continuation(self.root, **payload) + + def test_a_new_head_is_recorded_and_resolves_at_that_head(self): + outcome = self.record() + self.assertTrue(outcome["recorded"]) + self.assertEqual(outcome["sequence"], 2) + episodes = builder_lineage.load_episodes( + lane_handoff.lineage_root(self.root), REPO, PR + ) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, + episodes=episodes, opener_lane="devin", label_lanes=("codex",), + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.current_writer, "codex") + self.assertEqual(lineage.contributors, ("devin", "codex")) + + def test_without_a_continuation_the_lineage_stays_behind_the_head(self): + """This is the defect: the same round with nothing recorded.""" + + episodes = builder_lineage.load_episodes( + lane_handoff.lineage_root(self.root), REPO, PR + ) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, episodes=episodes, + ) + self.assertEqual(lineage.status, "waiting") + self.assertEqual(lineage.reason, "lineage_behind_head") + + def test_replay_is_idempotent(self): + self.assertTrue(self.record()["recorded"]) + replay = self.record() + self.assertFalse(replay["recorded"]) + self.assertTrue(replay["duplicate"]) + self.assertEqual(replay["sequence"], 2) + self.assertEqual( + len(builder_lineage.load_episodes(lane_handoff.lineage_root(self.root), REPO, PR)), + 2, + ) + + def test_stale_evidence_records_nothing(self): + outcome = self.record(expected_head="d" * 40) + self.assertFalse(outcome["recorded"]) + self.assertEqual(outcome["reason"], "continuation_unchained") + + def test_a_lane_that_is_not_the_current_writer_records_nothing(self): + outcome = self.record(lane="devin") + self.assertFalse(outcome["recorded"]) + self.assertEqual(outcome["reason"], "not_current_writer") + + def test_an_undelivered_or_unmoved_round_records_nothing(self): + self.assertEqual(self.record(delivered=False)["reason"], "delivery_unvalidated") + self.assertEqual(self.record(resulting_head=TAKEN)["reason"], "head_unchanged") + + def test_an_ordinary_single_builder_pr_records_nothing(self): + if True: + tmp = git_free_tempdir(self) + outcome = lane_handoff.record_continuation( + Path(tmp), repo=REPO, pr_number=777, branch=BRANCH, lane="claude", + expected_head=OPENED, resulting_head=TAKEN, delivered=True, + ) + self.assertFalse(outcome["recorded"]) + self.assertEqual(outcome["reason"], "no_recorded_lineage") + + def test_a_continuation_is_not_a_handoff(self): + """It cannot be forged into one, or manufacture a new contributor.""" + + self.record() + episodes = builder_lineage.load_episodes( + lane_handoff.lineage_root(self.root), REPO, PR + ) + self.assertEqual(episodes[1].kind, builder_lineage.CONTINUATION_KIND) + self.assertEqual(episodes[1].source_lane, episodes[1].destination_lane) + with self.assertRaises(builder_lineage.LineageError): + builder_lineage.ContributionEpisode( + sequence=2, kind=builder_lineage.HANDOFF_KIND, repo=REPO, pr_number=PR, + branch=BRANCH, source_lane="codex", destination_lane="codex", + expected_head=TAKEN, resulting_head=FIXED, writer_state="terminated", + ) + + def test_lineage_that_starts_with_a_continuation_fails_closed(self): + lone = builder_lineage.ContributionEpisode( + sequence=1, kind=builder_lineage.CONTINUATION_KIND, repo=REPO, pr_number=PR, + branch=BRANCH, source_lane="codex", destination_lane="codex", + expected_head=OPENED, resulting_head=TAKEN, + writer_state=builder_lineage.CONTINUATION_WRITER_STATE, + ) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=TAKEN, episodes=(lone,), + ) + self.assertEqual(lineage.status, "conflict") + + +class ClassifyRecordsContinuations(unittest.TestCase): + """The producer path the runner actually calls, not a helper in isolation.""" + + def test_classify_records_a_continuation_without_a_handoff(self): + if True: + tmp = git_free_tempdir(self) + root = tmp / "handoffs" + builder_lineage.record_episode(lane_handoff.lineage_root(root), takeover_episode()) + recorded = {} + + def fake(state_dir, **kwargs): + recorded.update(kwargs) + recorded["state_dir"] = state_dir + return {"recorded": True, "reason": "recorded", "sequence": 2} + + args = SimpleNamespace( + before=None, after=None, provider_exit=0, declared_outcome="delivered", + supervision="none", handoff=None, handoff_state_dir=root, lane="codex", + repo=REPO, signal=[], elapsed_seconds=1, user_interventions=0, + output=None, force=True, json=True, + ) + before = SimpleNamespace(head_sha=TAKEN, kind="pr", number=PR, branch=BRANCH) + after = SimpleNamespace(head_sha=FIXED, kind="pr", number=PR, branch=BRANCH) + with mock.patch.object(lane_delivery, "_load_state", side_effect=[before, after]), \ + mock.patch.object( + lane_delivery, "classify_delivery", + return_value=SimpleNamespace( + delivered=True, transition="head_moved", reason="ok", + as_dict=lambda: {"delivered": True}, + ), + ), \ + mock.patch.object(lane_delivery, "build_delivery_outcome_event", + return_value={"event_id": "e"}), \ + mock.patch.object(lane_delivery, "write_delivery_outcome_event"), \ + mock.patch.object(lane_handoff, "record_continuation", fake): + lane_delivery._classify_main(args) + self.assertEqual(recorded["lane"], "codex") + self.assertEqual(recorded["expected_head"], TAKEN) + self.assertEqual(recorded["resulting_head"], FIXED) + self.assertTrue(recorded["delivered"]) + + +class SnapshotCarriesTheValidatedBranch(unittest.TestCase): + """The snapshot/classification contract the continuation recorder consumes. + + ``_classify_main`` reads the head branch off the after-snapshot. The + snapshot producer is the only place that already holds an authenticated + pull request read, so the branch travels with the head it was read beside + rather than being resolved again later against a name that can move. + + These drive the real ``classify`` command end to end -- real snapshot files + in the producer's own format, the real recorder, the real store -- because + a ``SimpleNamespace`` standing in for :class:`TargetState` is exactly what + hid the missing field. + """ + + def setUp(self): + self.root = git_free_tempdir(self) / "handoffs" + self.store = lane_handoff.lineage_root(self.root) + + def snapshot(self, path: Path, head: str, **overrides) -> str: + payload = { + "kind": "pr", + "number": str(PR), + "pr_number": str(PR), + "head_sha": head, + "branch": BRANCH, + "pr_state": "OPEN", + "labels": ["builder:codex"], + "runner_comment_id": "", + "snapshot_complete": True, + } + payload.update(overrides) + path.write_text(json.dumps(payload), encoding="utf-8") + return str(path) + + def classify(self, before_path: str, after_path: str) -> dict: + outcomes = git_free_tempdir(self, "code-mower-outcomes-") + printed = io.StringIO() + with contextlib.redirect_stdout(printed): + code = lane_delivery.main([ + "classify", "--before", before_path, "--after", after_path, + "--provider-exit", "0", "--declared-outcome", "", + "--supervision", "completed", "--handoff-state-dir", str(self.root), + "--lane", "codex", "--repo", REPO, "--elapsed-seconds", "1", + "--user-interventions", "0", "--output", str(outcomes / "event.json"), + "--force", "--json", + ]) + self.assertEqual(code, 0) + self.assertTrue((outcomes / "event.json").exists()) + return json.loads(printed.getvalue()) + + def episodes(self): + return builder_lineage.load_episodes(self.store, REPO, PR) + + def test_target_state_round_trips_the_branch(self): + state = lane_delivery.TargetState.from_mapping({ + "kind": "pr", "number": str(PR), "pr_number": str(PR), + "head_sha": FIXED, "branch": BRANCH, "snapshot_complete": True, + }) + self.assertEqual(state.branch, BRANCH) + self.assertEqual(state.as_dict()["branch"], BRANCH) + self.assertEqual( + lane_delivery.TargetState.from_mapping(state.as_dict()).branch, BRANCH + ) + + def test_an_older_snapshot_without_a_branch_still_loads(self): + """Compatibility: the field is absent, not wrong.""" + + state = lane_delivery.TargetState.from_mapping({ + "kind": "pr", "number": str(PR), "pr_number": str(PR), + "head_sha": FIXED, "snapshot_complete": True, + }) + self.assertEqual(state.branch, "") + self.assertIn("branch", state.as_dict()) + + def test_a_branch_the_episode_contract_would_refuse_is_refused_here(self): + for bad in ("two words", "a\nb", "x" * 201): + with self.subTest(branch=bad): + with self.assertRaises(lane_delivery.LaneDeliveryError): + lane_delivery.TargetState.from_mapping({ + "kind": "pr", "number": str(PR), "pr_number": str(PR), + "head_sha": FIXED, "branch": bad, "snapshot_complete": True, + }) + + def test_an_accepted_takeover_then_an_ordinary_fix_round_is_recorded(self): + builder_lineage.record_episode(self.store, takeover_episode()) + tmp = git_free_tempdir(self, "code-mower-states-") + payload = self.classify( + self.snapshot(tmp / "before.json", TAKEN), + self.snapshot(tmp / "after.json", FIXED), + ) + self.assertEqual(payload["lineage"]["reason"], "recorded") + episodes = self.episodes() + self.assertEqual(len(episodes), 2) + self.assertEqual(episodes[1].kind, builder_lineage.CONTINUATION_KIND) + self.assertEqual(episodes[1].branch, BRANCH) + self.assertEqual(episodes[1].repo, REPO) + self.assertEqual(episodes[1].pr_number, PR) + self.assertEqual(episodes[1].expected_head, TAKEN) + self.assertEqual(episodes[1].resulting_head, FIXED) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, + episodes=episodes, opener_lane="devin", label_lanes=("codex",), + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.current_writer, "codex") + + def test_an_ordinary_pr_with_no_lineage_records_nothing_and_still_delivers(self): + tmp = git_free_tempdir(self, "code-mower-states-") + payload = self.classify( + self.snapshot(tmp / "before.json", TAKEN), + self.snapshot(tmp / "after.json", FIXED), + ) + self.assertEqual(payload["lineage"]["reason"], "no_recorded_lineage") + self.assertEqual(self.episodes(), ()) + + def test_a_snapshot_with_no_branch_refuses_rather_than_inheriting_one(self): + """Fail closed: no branch observed is not licence to reuse the tip's.""" + + builder_lineage.record_episode(self.store, takeover_episode()) + tmp = git_free_tempdir(self, "code-mower-states-") + payload = self.classify( + self.snapshot(tmp / "before.json", TAKEN, branch=""), + self.snapshot(tmp / "after.json", FIXED, branch=""), + ) + self.assertEqual(payload["lineage"]["reason"], "branch_unobserved") + self.assertFalse(payload["lineage"]["recorded"]) + self.assertEqual(len(self.episodes()), 1) + + def test_a_branch_that_disagrees_with_the_lineage_fails_closed(self): + builder_lineage.record_episode(self.store, takeover_episode()) + tmp = git_free_tempdir(self, "code-mower-states-") + before = self.snapshot(tmp / "before.json", TAKEN) + after = self.snapshot(tmp / "after.json", FIXED, branch="codex/other-branch") + with self.assertRaises(lane_delivery.LaneDeliveryError): + lane_delivery._classify_main(SimpleNamespace( + before=before, after=after, provider_exit=0, + declared_outcome="", supervision="completed", handoff=None, + handoff_state_dir=self.root, lane="codex", repo=REPO, signal=[], + elapsed_seconds=1, user_interventions=0, output=None, + force=True, json=True, + )) + self.assertEqual(len(self.episodes()), 1) + + +class SnapshotProducerReadsTheBranch(unittest.TestCase): + """Every runner mirror asks for the head branch and carries it through. + + The maintained template, the packaged template and the vendored rendered + runner are separate files. A producer that still asks GitHub only for + ``headRefOid,state,labels`` writes a snapshot with no branch, and the + recorder above then refuses every continuation. + """ + + MIRRORS = ( + "templates/lanes/run_mac_lane.sh", + "src/code_mower/templates/lanes/run_mac_lane.sh", + "tools/lanes/run_mac_lane.sh", + ) + + def test_each_mirror_requests_and_emits_the_head_branch(self): + root = Path(__file__).resolve().parents[1] + for relative in self.MIRRORS: + with self.subTest(mirror=relative): + text = root.joinpath(relative).read_text(encoding="utf-8") + self.assertIn("--json headRefName,headRefOid,state,labels", text) + self.assertIn('branch: (.headRefName // ""),', text) + self.assertNotIn("--json headRefOid,state,labels", text) + + def test_the_producers_jq_program_emits_a_loadable_snapshot(self): + """Run the producer's own transform, then load it as the CLI would.""" + + program = """ + { + kind: $kind, + number: $number, + pr_number: $pr, + head_sha: ((.headRefOid // "") | ascii_downcase), + branch: (.headRefName // ""), + pr_state: (.state // ""), + labels: $labels, + runner_comment_id: $comment, + snapshot_complete: $complete + }""" + for relative in self.MIRRORS: + with self.subTest(mirror=relative): + self.assertIn( + program, + Path(__file__).resolve().parents[1] + .joinpath(relative).read_text(encoding="utf-8"), + ) + completed = subprocess.run( + ["jq", "--arg", "kind", "pr", "--arg", "number", str(PR), + "--arg", "pr", str(PR), "--arg", "comment", "", + "--argjson", "labels", '["builder:codex"]', + "--argjson", "complete", "true", program], + input=json.dumps({ + "headRefName": BRANCH, "headRefOid": FIXED.upper(), + "state": "OPEN", "labels": [{"name": "builder:codex"}], + }), + text=True, capture_output=True, check=True, + ) + state = lane_delivery.TargetState.from_mapping(json.loads(completed.stdout)) + self.assertEqual(state.branch, BRANCH) + self.assertEqual(state.head_sha, FIXED) + self.assertTrue(state.snapshot_complete) + + +class PublishBeforeReconcile(unittest.TestCase): + """codex:4725ce39cb69b7ac68e7 -- the gate reads comments, not the store.""" + + def setUp(self): + self.root = git_free_tempdir(self) / "handoffs" + builder_lineage.record_episode(lane_handoff.lineage_root(self.root), takeover_episode()) + self.published: list[str] = [] + self.labelled: list[tuple] = [] + + def run_lineage(self, *, head=TAKEN, labels=("builder:devin",), bodies=None): + args = SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=head, labels=list(labels), + author="devin-ai-integration[bot]", state_dir=self.root, + identity_json=json.dumps(IDENTITY), publish=True, reconcile_labels=True, + json=True, + ) + # Publication now requires a configured decision authority, because a + # marker no consumer would read is not evidence: codex:6dfb331284ea. + authority = "codemower-ai" + with mock.patch.dict( + os.environ, + { + "CODE_MOWER_DECISION_AUTHORITIES": authority, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + }, + clear=False, + ): + return lane_delivery._lineage_main( + args, + head=lambda repo, number: head, + labels=lambda repo, number, add, remove: self.labelled.append( + (add, remove) + ), + comment_bodies=lambda repo, number: tuple( + {"user": {"login": authority}, "body": body} + for body in (bodies if bodies is not None else self.published) + ), + publish_comment=lambda repo, number, body: self.published.append(body), + ) + + def test_evidence_is_published_and_then_the_label_moves(self): + self.assertEqual(self.run_lineage(), 0) + self.assertEqual(len(self.published), 1) + marker = self.published[0] + self.assertIn(builder_lineage.LINEAGE_MARKER, marker) + self.assertEqual(self.labelled, [(("builder:codex",), ("builder:devin",))]) + + def test_the_published_marker_is_what_the_gate_reads(self): + self.run_lineage() + episodes = builder_lineage.episodes_from_comment_body(self.published[0]) + self.assertEqual(len(episodes), 1) + lineage = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=TAKEN, episodes=episodes, + opener_lane="devin", label_lanes=("codex",), + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.current_writer, "codex") + self.assertEqual(lineage.contributors, ("devin", "codex")) + + def test_publication_carries_only_bounded_metadata(self): + self.run_lineage() + payload = json.loads( + self.published[0].split(builder_lineage.LINEAGE_MARKER, 1)[1].rsplit("-->", 1)[0] + ) + for episode in payload["episodes"]: + self.assertEqual( + set(episode), set(builder_lineage.EPISODE_FIELDS), + "no field beyond the bounded episode contract may be published", + ) + body = self.published[0].lower() + # Built rather than written out: the privacy scanner reads this file + # too, and a literal host path here is the thing it exists to reject. + for leaked in ("/users/", "/private/" + "tmp", "session", "prompt", "token"): + self.assertNotIn(leaked, body) + + def test_publication_is_idempotent(self): + self.run_lineage() + already = list(self.published) + self.assertEqual(self.run_lineage(bodies=already), 0) + self.assertEqual(self.published, already) + + def test_unresolved_lineage_publishes_nothing_and_moves_no_label(self): + self.assertEqual(self.run_lineage(head=FIXED), 3) + self.assertEqual(self.published, []) + self.assertEqual(self.labelled, []) + + def test_a_failed_publication_leaves_the_label_alone(self): + def explode(repo, number, body): + raise subprocess.CalledProcessError(1, "gh") + + args = SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=TAKEN, labels=["builder:devin"], + author="devin-ai-integration[bot]", state_dir=self.root, + identity_json=json.dumps(IDENTITY), publish=True, reconcile_labels=True, + json=True, + ) + with mock.patch.dict( + os.environ, + { + "CODE_MOWER_DECISION_AUTHORITIES": "codemower-ai", + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + }, + clear=False, + ), self.assertRaises(subprocess.CalledProcessError): + lane_delivery._lineage_main( + args, + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: self.labelled.append((add, remove)), + comment_bodies=lambda repo, number: (), + publish_comment=explode, + ) + self.assertEqual(self.labelled, []) + + def test_no_configured_authority_publishes_nothing_and_moves_no_label(self): + """codex:6dfb331284ea -- a marker no consumer reads is not evidence.""" + + args = SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=TAKEN, labels=["builder:devin"], + author="devin-ai-integration[bot]", state_dir=self.root, + identity_json=json.dumps(IDENTITY), publish=True, reconcile_labels=True, + json=True, + ) + with mock.patch.dict( + os.environ, + { + "CODE_MOWER_DECISION_AUTHORITIES": "", + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + }, + clear=False, + ): + code = lane_delivery._lineage_main( + args, + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: self.labelled.append((add, remove)), + comment_bodies=lambda repo, number: (), + publish_comment=lambda repo, number, body: self.published.append(body), + ) + self.assertEqual(code, 3) + self.assertEqual(self.published, [], "nothing may be published") + self.assertEqual(self.labelled, [], "no label may be reconciled") + + +class LabelerCallersCarryLineage(unittest.TestCase): + """codex:9bac84962f544d9fa4bb -- trusted evidence reaches the labelers.""" + + def setUp(self): + marker = builder_lineage.lineage_comment_marker((takeover_episode(),)) + self.comments = [ + {"user": {"login": "codemower-ai"}, "body": "lineage\n" + marker}, + {"user": {"login": "random-person"}, "body": "hello"}, + ] + + def context(self, comments=None, head=TAKEN): + return lineage_context( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=head, + comments=self.comments if comments is None else comments, + trusted_author=lineage_marker_author_trust(authorities=("codemower-ai",)), + ) + + def test_an_independent_claude_reviewer_may_update_its_done_label(self): + """Devin author + reconciled Codex label used to look like a conflict.""" + + self.assertIsNone( + author_exclusion_reason( + lane_name="claude", labels=["builder:codex"], + author="devin-ai-integration[bot]", text="", config=IDENTITY, + lineage=self.context(), + ) + ) + + def test_identity_only_resolution_would_have_skipped_it(self): + """The defect the finding describes, kept as an explicit regression.""" + + self.assertEqual( + author_exclusion_reason( + lane_name="claude", labels=["builder:codex"], + author="devin-ai-integration[bot]", text="", config=IDENTITY, + lineage=NO_LINEAGE, + ), + "conflicting builder identity; skipping author-excluded label update", + ) + + def test_every_contributor_stays_excluded(self): + for lane in ("devin", "codex"): + self.assertEqual( + author_exclusion_reason( + lane_name=lane, labels=["builder:codex"], + author="devin-ai-integration[bot]", text="", config=IDENTITY, + lineage=self.context(), + ), + f"{lane} lane excluded for builder-authored PR", + ) + + def test_matches_name_both_contributors_in_order(self): + self.assertEqual( + builder_identity_matches( + labels=["builder:codex"], author="devin-ai-integration[bot]", text="", + config=IDENTITY, lineage=self.context(), + ), + ("devin", "codex"), + ) + + def test_a_marker_from_an_untrusted_author_is_not_evidence(self): + untrusted = [{"user": {"login": "random-person"}, "body": self.comments[0]["body"]}] + self.assertEqual(self.context(comments=untrusted).episodes, ()) + + def test_lineage_behind_the_head_skips_rather_than_guesses(self): + self.assertEqual( + author_exclusion_reason( + lane_name="claude", labels=["builder:codex"], + author="devin-ai-integration[bot]", text="", config=IDENTITY, + lineage=self.context(head=FIXED), + ), + "builder contribution lineage is behind the current head; " + "skipping author-excluded label update", + ) + + def test_malformed_published_evidence_fails_closed(self): + broken = [{ + "user": {"login": "codemower-ai"}, + "body": f"", + }] + with self.assertRaises(builder_lineage.LineageError): + self.context(comments=broken) + + def test_an_unverified_head_carries_no_evidence(self): + self.assertEqual(self.context(head="").episodes, ()) + self.assertEqual(self.context(head="").repo, "") + + def test_an_ordinary_single_builder_pr_is_unaffected(self): + context = lineage_context( + repo=REPO, pr_number=777, branch=BRANCH, head_sha=OPENED, + comments=[], trusted_author=lineage_marker_author_trust(authorities=()), + ) + self.assertIsNone( + author_exclusion_reason( + lane_name="codex", labels=["builder:claude"], author="claude[bot]", + text="", config=IDENTITY, lineage=context, + ) + ) + self.assertEqual( + author_exclusion_reason( + lane_name="claude", labels=["builder:claude"], author="claude[bot]", + text="", config=IDENTITY, lineage=context, + ), + "claude lane excluded for builder-authored PR", + ) + + +class TrailerLabelerEntryPath(unittest.TestCase): + """The real trailer labeler decision, not the helper it calls.""" + + def test_resolve_label_decision_threads_trusted_lineage(self): + from code_mower import trailer_comment_labeler as labeler + + marker = builder_lineage.lineage_comment_marker((takeover_episode(),)) + seen = {} + + def capture(**kwargs): + seen.update(kwargs) + return None + + event = { + "action": "created", + "issue": { + "number": PR, + "pull_request": {}, + "labels": [{"name": "builder:codex"}], + "user": {"login": "devin-ai-integration[bot]"}, + "body": "", + }, + "comment": {"id": 1, "user": {"login": "codemower-ai"}, "body": "x"}, + } + config = SimpleNamespace( + name="claude", + comment_authors=lambda: {"codemower-ai"}, + is_configured_comment_author=lambda author: False, + is_default_comment_author=lambda author: True, + trailer_prefix="Claude-Audit", + display_name="Claude", + ) + with mock.patch.object(labeler, "author_exclusion_reason", capture), \ + mock.patch.object(labeler, "classify_audit_comment", return_value="done"), \ + mock.patch.object( + labeler.code_mower_decisions, + "collect_decision_records_from_comments", return_value=(), + ): + labeler.resolve_label_decision( + event, + current_head_sha=TAKEN, + config=config, + repo=REPO, + head_branch=BRANCH, + issue_comments=[{"user": {"login": "codemower-ai"}, "body": marker}], + decision_authorities=("codemower-ai",), + ) + self.assertEqual(seen["lineage"].repo, REPO) + self.assertEqual(seen["lineage"].head_sha, TAKEN) + self.assertEqual(seen["lineage"].branch, BRANCH) + self.assertEqual(len(seen["lineage"].episodes), 1) + + +class SaaSLabelerEntryPath(unittest.TestCase): + """Every SaaS reviewer entry path receives the same resolved lineage.""" + + def test_each_event_shape_receives_the_lineage(self): + from code_mower import saas_reviewer_labeler as labeler + + marker = builder_lineage.lineage_comment_marker((takeover_episode(),)) + comments = [{"user": {"login": "codemower-ai"}, "body": marker}] + adapter = SimpleNamespace( + name="greptile", event_type="issue_comment", opt_in_required=False, + label_prefix="greptile", needs_label="n", done_label="d", blocked_label="b", + is_opted_in=lambda labels: True, is_review_author=lambda author: True, + is_check_run_author=lambda check_run: True, + is_check_run_name=lambda check_run: True, + ) + for event_type, event in ( + ("pull_request_review", {"action": "submitted", "pull_request": {"number": PR}}), + ( + "issue_comment", + { + "action": "created", + "issue": {"number": PR, "pull_request": {}}, + "comment": {"user": {"login": "bot"}, "body": "x"}, + }, + ), + ( + "check_run", + {"action": "completed", "check_run": {"status": "completed"}}, + ), + ): + with self.subTest(event_type=event_type): + seen = {} + + def capture(*, _seen=seen, **kwargs): + _seen.update(kwargs) + return "stop" + + with mock.patch.object(labeler, "author_exclusion_reason", capture): + labeler.resolve_label_decision( + event, + adapter=adapter, + event_type=event_type, + pr_number=PR, + pr_labels=["builder:codex"], + pr_author="devin-ai-integration[bot]", + pr_body="", + current_head_sha=TAKEN, + repo=REPO, + head_branch=BRANCH, + issue_comments=comments, + decision_authorities=("codemower-ai",), + ) + self.assertEqual(seen["lineage"].head_sha, TAKEN) + self.assertEqual(len(seen["lineage"].episodes), 1) + + def test_unreadable_published_lineage_skips_the_update(self): + from code_mower import saas_reviewer_labeler as labeler + + adapter = SimpleNamespace( + name="greptile", event_type="issue_comment", opt_in_required=False, + label_prefix="greptile", needs_label="n", done_label="d", blocked_label="b", + is_opted_in=lambda labels: True, is_review_author=lambda author: True, + is_check_run_author=lambda check_run: True, + is_check_run_name=lambda check_run: True, + ) + decision, reason = labeler.resolve_label_decision( + {"action": "created", "issue": {"number": PR, "pull_request": {}}}, + adapter=adapter, + event_type="issue_comment", + pr_number=PR, + current_head_sha=TAKEN, + repo=REPO, + issue_comments=[{ + "user": {"login": "codemower-ai"}, + "body": f"", + }], + decision_authorities=("codemower-ai",), + ) + self.assertIsNone(decision) + self.assertIn("unreadable", reason) + + +class RoleEligibilityIsSeparate(unittest.TestCase): + """Contribution independence and role qualification are different questions.""" + + def test_admission_never_consults_role_eligibility(self): + source = Path("src/code_mower/provider_runners/lineage.py").read_text(encoding="utf-8") + self.assertNotIn("role_eligibility", source.replace( + "code_mower.role_eligibility", "" + ).replace("mod:``", "")) + + def test_an_independent_lane_is_admitted_on_contribution_grounds_alone(self): + pr_meta = { + "labels": [{"name": "builder:codex"}], + "user": {"login": "devin-ai-integration[bot]"}, + "head": {"ref": BRANCH}, + } + decision = reviewer_lineage.reviewer_admission( + "claude", repo=REPO, pr_number=PR, pr_meta=pr_meta, head_sha=TAKEN, + episodes=(takeover_episode(),), identity=IDENTITY, + ) + self.assertTrue(decision["admitted"]) + self.assertEqual(decision["contributors"], ["devin", "codex"]) + self.assertNotIn("role", json.dumps(decision)) + + +VENDORED_MIRRORS = ("audit_labeler_lib.py", "builder_lineage.py", "decisions.py") + +#: Exactly what a generated gate runner executes: the repository's own +#: ``tools/`` copies, imported as the ``tools`` package, with no Code Mower +#: package and nothing from ``src/`` reachable. Asserting against the package +#: source would pass while the vendored copy raises ``NameError``. +VENDORED_PROBE = ''' +import json + +import tools.audit_labeler_lib as labeler +import tools.builder_lineage as lineage + +REPO = "codemower-ai/code-mower" +PR = 959 +BRANCH = "devin/959-thing" +OPENED = "a" * 40 +TAKEN = "b" * 40 +FIXED = "c" * 40 + +handoff = lineage.ContributionEpisode( + sequence=1, kind=lineage.HANDOFF_KIND, repo=REPO, pr_number=PR, branch=BRANCH, + source_lane="devin", destination_lane="codex", expected_head=OPENED, + resulting_head=TAKEN, writer_state="terminated", +) +continued = lineage.continuation_episode(handoff, lane="codex", resulting_head=FIXED) +marker = lineage.lineage_comment_marker([handoff, continued]) + +# The vendored decisions copy decides who may publish lineage at all. +authorities = labeler.lineage_decision_authorities() +trusted = labeler.lineage_marker_author_trust(authorities=authorities) + +# One authorised republication per round, exactly as the producer posts it. +published = labeler.published_lineage_episodes( + [{"user": {"login": "codemower-ai"}, "body": marker}] * 20 + + [{"user": {"login": "codex[bot]"}, "body": marker}], + trusted_author=trusted, +) +resolved = lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, episodes=published, + opener_lane="devin", label_lanes=["codex"], +) +print(json.dumps({ + "authorities": list(authorities), + "arrivals": len(published), + "status": resolved.status, + "reason": resolved.reason, + "writer": resolved.current_writer, + "contributors": list(resolved.contributors), + "episodes": resolved.episodes, +})) +''' + + +class VendoredToolMirrors(unittest.TestCase): + """The shipped ``tools/`` copies must be the canonical implementations. + + CI lints and a generated product gate imports the vendored files, not the + package ones. Drift here is invisible to every assertion that reaches into + ``src/code_mower``, and it has already produced an F821 plus a runtime + ``NameError`` in published-lineage parsing. + """ + + def test_vendored_copies_match_their_canonical_sources(self): + for name in VENDORED_MIRRORS: + with self.subTest(module=name): + canonical = Path("src/code_mower") / name + vendored = Path("tools") / name + self.assertEqual( + vendored.read_text(encoding="utf-8"), + canonical.read_text(encoding="utf-8"), + f"tools/{name} has drifted from src/code_mower/{name}", + ) + + def test_vendored_labeler_imports_every_name_it_uses(self): + """The F821: LINEAGE_MARKER was used but never imported here.""" + + source = Path("tools/audit_labeler_lib.py").read_text(encoding="utf-8") + self.assertIn("LINEAGE_MARKER not in body", source) + # One import per branch: packaged relative, copied-tools fallback and + # direct helper execution. Any of the three missing is an F821. + self.assertEqual(3, source.count("LINEAGE_MARKER,")) + + def _run_vendored_probe(self, environment_extra): + root = git_free_tempdir(self) + tools = root / "tools" + tools.mkdir() + (tools / "__init__.py").write_text("", encoding="utf-8") + for target, _package_copy_from, _, _ in init.PRODUCT_SUPPORT_FILES: + if not (target.startswith("tools/") and target.endswith(".py")): + continue + vendored = Path(target) + if not vendored.exists(): # templated wrappers, not vendored modules + continue + (root / target).write_bytes(vendored.read_bytes()) + (root / "probe.py").write_text(VENDORED_PROBE, encoding="utf-8") + environment = { + key: value + for key, value in os.environ.items() + if key not in {"PYTHONPATH", "PYTHONHOME"} + and not key.startswith("CODE_MOWER_DECISION_AUTHORITIES") + } + environment.update(environment_extra) + result = subprocess.run( + [sys.executable, "probe.py"], + cwd=root, capture_output=True, text=True, timeout=120, env=environment, + ) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout.strip().splitlines()[-1]) + + def test_vendored_modules_parse_markers_and_replay_at_the_exact_head(self): + """Marker parsing, authority override and idempotent replay, vendored.""" + + payload = self._run_vendored_probe({ + # The override the canonical decisions module honours. The stale + # vendored copy read only the base variable, so it would trust the + # wrong account and read no published episodes at all. + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "codemower-ai", + "CODE_MOWER_DECISION_AUTHORITIES": "someone-else", + }) + self.assertEqual(payload["authorities"], ["codemower-ai"]) + # Twenty authorised republications of a two-episode chain; the audit + # bot's byte-identical marker is not an authority and is not read. + self.assertEqual(payload["arrivals"], 40) + self.assertEqual(payload["status"], "resolved", payload["reason"]) + self.assertEqual(payload["episodes"], 2) + self.assertEqual(payload["writer"], "codex") + self.assertEqual(payload["contributors"], ["devin", "codex"]) + + def test_vendored_authority_override_is_the_only_marker_trust(self): + """No override configured, no authority: nothing is read or resolved.""" + + payload = self._run_vendored_probe({"CODE_MOWER_DECISION_AUTHORITIES": ""}) + self.assertEqual(payload["authorities"], []) + self.assertEqual(payload["arrivals"], 0) + # No episodes at all is the ordinary single-builder answer, not a guess. + self.assertEqual(payload["episodes"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_builder_lineage_entrypoints.py b/tests/test_builder_lineage_entrypoints.py new file mode 100644 index 00000000..20c28a80 --- /dev/null +++ b/tests/test_builder_lineage_entrypoints.py @@ -0,0 +1,2112 @@ +"""Regressions for the three seams that *obtain* lineage authority. + +``test_builder_lineage_consumers`` proves each helper carries lineage when it +is handed some, and ``test_builder_lineage_integration`` proves the producer +publishes. These prove the three production entrypoints between them do the +right thing under the repository's configured decision-authority contract: + +* the delivery CLI refuses to publish or move a label when no authority is + configured, because every consumer would then trust no marker at all; +* every maintained SaaS labeler entrypoint fetches the exact current head, the + head branch and the bounded trusted comment history before it decides, and + stops instead of mutating labels when a required read fails; +* the generated provenance job supplies the same authority contract to + ``builder auto-record``, so a verified takeover is attributed to its current + writer rather than to whoever opened the pull request. + +Each case drives the real entrypoint -- ``lane_delivery.main``, +``saas_reviewer_labeler.main`` and the rendered workflow's own command line -- +with only the network boundary replaced. +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import subprocess +import sys +import unittest +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Mapping +from unittest import mock + +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from code_mower import builder_lineage, lane_delivery, lane_handoff # noqa: E402 +from code_mower import saas_reviewer_labeler as labeler # noqa: E402 +from code_mower.provider_runners import lineage as reviewer_lineage # noqa: E402 + +from test_builder_lineage_consumers import ( # noqa: E402 + BRANCH, + PR, + REPO, + TAKEN, + git_free_tempdir, + takeover_episode, +) + +ROOT = Path(__file__).resolve().parents[1] +AUTHORITY = "codemower-ai" +OUTSIDER = "passer-by" +OPENER = "devin-ai-integration[bot]" +MARKER_BODY = "Builder contribution lineage for this head.\n\n" + + +@contextmanager +def _private_root(case: unittest.TestCase): + """A store root outside any checkout, as the context store demands.""" + + yield str(git_free_tempdir(case)) + + +def marker_comment(author: str = AUTHORITY) -> dict: + return { + "user": {"login": author}, + "body": MARKER_BODY + + builder_lineage.lineage_comment_marker((takeover_episode(),)), + } + + +class _FakeGh: + """Just enough `gh` for the delivery CLI: comments, publish, label edit.""" + + def __init__(self, comments=(), publish_as: str = AUTHORITY, head: str = TAKEN): + self.comments = [dict(item) for item in comments] + self.publish_as = publish_as + self.head = head + self.posted: list[str] = [] + self.label_commands: list[list[str]] = [] + + def check_output(self, command, **_kwargs): + if "--json" in command and "headRefOid" in command: + return json.dumps({"headRefOid": self.head}) + if "--json" in command and "comments" in command: + return json.dumps( + { + "comments": [ + { + "author": {"login": item["user"]["login"]}, + "body": item["body"], + } + for item in self.comments + ] + } + ) + raise AssertionError(f"unexpected gh read: {command}") + + def run(self, command, **_kwargs): + if command[:3] == ["gh", "pr", "comment"]: + body = command[command.index("--body") + 1] + self.posted.append(body) + self.comments.append({"user": {"login": self.publish_as}, "body": body}) + return subprocess.CompletedProcess(command, 0) + if command[:3] == ["gh", "pr", "edit"]: + self.label_commands.append(list(command)) + return subprocess.CompletedProcess(command, 0) + raise AssertionError(f"unexpected gh write: {command}") + + +class PublisherRequiresATrustedAuthority(unittest.TestCase): + """The delivery CLI never publishes evidence no consumer would read.""" + + def _run(self, gh, *, authorities: str, extra=()): + with _private_root(self) as root: + builder_lineage.record_episode( + lane_handoff.lineage_root(Path(root)), takeover_episode() + ) + argv = [ + "lineage", + "--repo", REPO, + "--pr", str(PR), + "--branch", BRANCH, + "--head", TAKEN, + "--author", OPENER, + "--label", "builder:devin", + "--state-dir", str(root), + "--publish", + "--reconcile-labels", + "--json", + *extra, + ] + env = {"CODE_MOWER_DECISION_AUTHORITIES": authorities} + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch.object(lane_delivery.subprocess, "check_output", + gh.check_output), \ + mock.patch.object(lane_delivery.subprocess, "run", gh.run), \ + mock.patch("sys.stdout", new_callable=_Capture) as out: + code = lane_delivery.main(argv) + return code, json.loads(out.text()) + + def test_no_configured_authority_publishes_nothing_and_moves_no_label(self): + gh = _FakeGh() + code, payload = self._run(gh, authorities="") + self.assertEqual(code, 3) + self.assertEqual(payload["reason"], "lineage_no_decision_authority") + self.assertTrue(payload["owner_action"]) + self.assertFalse(payload["applied"]) + self.assertEqual(gh.posted, [], "nothing may be published") + self.assertEqual(gh.label_commands, [], "no label may be reconciled") + + def test_an_identical_untrusted_marker_does_not_suppress_publication(self): + gh = _FakeGh(comments=[marker_comment(OUTSIDER)]) + code, payload = self._run(gh, authorities=AUTHORITY) + self.assertEqual(code, 0) + self.assertTrue(payload["published"]) + self.assertEqual(len(gh.posted), 1) + + def test_a_configured_trusted_publisher_publishes_then_reconciles(self): + gh = _FakeGh() + code, payload = self._run(gh, authorities=f"{AUTHORITY},someone-else") + self.assertEqual(code, 0) + self.assertTrue(payload["published"]) + self.assertEqual(len(gh.posted), 1) + self.assertTrue(gh.label_commands, "the verified writer takes the label") + + def test_an_unreadable_readback_blocks_the_label(self): + gh = _FakeGh(publish_as=OUTSIDER) + code, payload = self._run(gh, authorities=AUTHORITY) + self.assertEqual(code, 3) + self.assertEqual(payload["reason"], "lineage_unpublished") + self.assertTrue(payload["owner_action"]) + self.assertEqual(len(gh.posted), 1, "the attempt happened; it did not count") + self.assertEqual(gh.label_commands, [], "no label may be reconciled") + + +class PublicationIsDecidedSemantically(unittest.TestCase): + """The expected text being present is not a verified publication. + + Idempotency and readback used a substring test, so a body holding that + text beside a broken marker -- or a second trusted comment carrying a + different chain -- counted as "already published", and the builder label + moved on a history consumers would not resolve the same way. + """ + + def _publish(self, comments, *, publish_as=AUTHORITY): + posted: list[str] = [] + existing = list(comments) + + def record(body): + posted.append(body) + existing.append({"user": {"login": publish_as}, "body": body}) + + result = lane_delivery.publish_lineage_evidence( + repo=REPO, pr_number=str(PR), branch=BRANCH, head_sha=TAKEN, + episodes=(takeover_episode(),), + opener_lane="devin", label_lanes=("codex",), + existing_bodies=lambda: list(existing), + publish=record, + trusted_author=reviewer_lineage.marker_author_trust((AUTHORITY,)), + ) + return result, posted + + def _valid_marker(self): + return MARKER_BODY + builder_lineage.lineage_comment_marker( + (takeover_episode(),) + ) + + def test_a_valid_trusted_duplicate_is_still_idempotent(self): + result, posted = self._publish( + [{"user": {"login": AUTHORITY}, "body": self._valid_marker()}] + ) + self.assertTrue(result["duplicate"]) + self.assertEqual(posted, []) + + def test_the_expected_text_beside_a_broken_marker_is_not_a_publication(self): + broken = self._valid_marker() + "\n\n", 1)[0].strip()) + for episode in payload["episodes"]: + self.assertEqual( + sorted(episode), sorted(builder_lineage.EPISODE_FIELDS) + ) + + +class PublishThenReconcileCli(unittest.TestCase): + """The real ``lane-delivery lineage`` path, publication before the label.""" + + def setUp(self): + self.root = git_free_tempdir(self, "code-mower-producer-") + builder_lineage.record_episode( + lane_handoff.lineage_root(self.root), takeover_episode() + ) + patched = mock.patch.dict( + "os.environ", {"CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY} + ) + patched.start() + self.addCleanup(patched.stop) + + def _args(self, **overrides): + args = SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=TAKEN, + labels=["builder:devin"], author="devin-ai-integration[bot]", + identity_json=json.dumps(IDENTITY), state_dir=self.root, + publish=True, reconcile_labels=True, json=True, + ) + for key, value in overrides.items(): + setattr(args, key, value) + return args + + def test_an_untrusted_duplicate_does_not_block_the_required_publication(self): + existing = [published([takeover_episode()], author=OUTSIDER)] + posted, applied = [], [] + code = lane_delivery._lineage_main( + self._args(), + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: applied.append((add, remove)), + comment_bodies=lambda repo, number: list(existing), + publish_comment=lambda repo, number, body: ( + posted.append(body), + existing.append({"user": {"login": AUTHORITY}, "body": body}), + ), + ) + self.assertEqual(code, 0) + self.assertEqual(len(posted), 1) + self.assertEqual(applied, [(("builder:codex",), ("builder:devin",))]) + + def test_publication_failure_abandons_the_label_move(self): + applied = [] + + def refuse(repo, number, body): + raise RuntimeError("comment rejected") + + with self.assertRaises(RuntimeError): + lane_delivery._lineage_main( + self._args(), + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: applied.append((add, remove)), + comment_bodies=lambda repo, number: [], + publish_comment=refuse, + ) + self.assertEqual(applied, []) + + def test_a_publication_no_consumer_trusts_blocks_the_label_move(self): + existing, applied = [], [] + code = lane_delivery._lineage_main( + self._args(), + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: applied.append((add, remove)), + comment_bodies=lambda repo, number: list(existing), + publish_comment=lambda repo, number, body: existing.append( + {"user": {"login": OUTSIDER}, "body": body} + ), + ) + self.assertEqual(code, 3) + self.assertEqual(applied, []) + + def test_a_head_that_moved_under_the_record_publishes_nothing(self): + existing, applied = [], [] + code = lane_delivery._lineage_main( + self._args(head=FIXED), + head=lambda repo, number: FIXED, + labels=lambda repo, number, add, remove: applied.append((add, remove)), + comment_bodies=lambda repo, number: list(existing), + publish_comment=lambda repo, number, body: existing.append( + {"user": {"login": AUTHORITY}, "body": body} + ), + ) + self.assertEqual(code, 3) + self.assertEqual(existing, []) + self.assertEqual(applied, []) + + +class GoldenTakeoverChain(unittest.TestCase): + """Producer -> published comment -> empty-store reviewer -> labeler -> gate.""" + + def setUp(self): + self.producer_root = git_free_tempdir(self, "code-mower-golden-producer-") + self.reviewer_root = git_free_tempdir(self, "code-mower-golden-reviewer-") + builder_lineage.record_episode( + lane_handoff.lineage_root(self.producer_root), takeover_episode() + ) + patched = mock.patch.dict( + "os.environ", + { + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(IDENTITY), + }, + ) + patched.start() + self.addCleanup(patched.stop) + + def _publish_from_the_producer(self): + comments = [] + code = lane_delivery._lineage_main( + SimpleNamespace( + repo=REPO, pr=str(PR), branch=BRANCH, head=TAKEN, + labels=["builder:devin"], author="devin-ai-integration[bot]", + identity_json=json.dumps(IDENTITY), state_dir=self.producer_root, + publish=True, reconcile_labels=True, json=True, + ), + head=lambda repo, number: TAKEN, + labels=lambda repo, number, add, remove: None, + comment_bodies=lambda repo, number: list(comments), + publish_comment=lambda repo, number, body: comments.append( + {"user": {"login": AUTHORITY}, "body": body} + ), + ) + self.assertEqual(code, 0) + self.assertEqual(len(comments), 1) + return comments + + def test_the_whole_chain_admits_claude_and_lets_it_satisfy_the_gate(self): + from code_mower import claude_audit_pr + from code_mower.audit_labeler_lib import ( + author_exclusion_reason, + builder_identity_matches, + lineage_context, + lineage_marker_author_trust, + ) + + comments = self._publish_from_the_producer() + meta = pr_meta() + + with mock.patch.dict( + "os.environ", {lane_handoff.STATE_DIR_ENV: str(self.reviewer_root)} + ): + admission = claude_audit_pr._require_independent_review( + "claude", REPO, PR, meta, TAKEN, + authorities=(AUTHORITY,), + fetch_comments=lambda: comments, + ) + self.assertTrue(admission["admitted"]) + + context = lineage_context( + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + comments=comments, + trusted_author=lineage_marker_author_trust(authorities=(AUTHORITY,)), + ) + self.assertEqual(len(context.episodes), 1) + + # The labeler updates Claude's own done label rather than skipping it. + self.assertIsNone( + author_exclusion_reason( + lane_name="claude", + labels=["builder:codex"], + author="devin-ai-integration[bot]", + text="", + config=IDENTITY, + lineage=context, + ) + ) + # And the gate reads the same evidence, naming both contributors. + self.assertEqual( + sorted( + builder_identity_matches( + labels=["builder:codex"], + author="devin-ai-integration[bot]", + text="", + config=IDENTITY, + lineage=context, + ) + ), + ["codex", "devin"], + ) + + def test_a_same_writer_continuation_keeps_the_chain_at_the_new_head(self): + comments = self._publish_from_the_producer() + comments.append(published([takeover_episode(), continuation_episode()])) + + with mock.patch.dict( + "os.environ", {lane_handoff.STATE_DIR_ENV: str(self.reviewer_root)} + ): + episodes = reviewer_lineage.reviewer_evidence( + REPO, PR, authorities=(AUTHORITY,), fetch_comments=lambda: comments + ) + resolved = reviewer_lineage.pr_lineage( + repo=REPO, + pr_number=PR, + pr_meta=pr_meta(head=FIXED), + head_sha=FIXED, + episodes=episodes, + identity=IDENTITY, + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.current_writer, "codex") + self.assertEqual(sorted(resolved.contributors), ["codex", "devin"]) + + def test_conflicting_published_evidence_fails_closed(self): + comments = self._publish_from_the_producer() + comments.append( + published([_variant(takeover_episode(), destination_lane="claude")]) + ) + with mock.patch.dict( + "os.environ", {lane_handoff.STATE_DIR_ENV: str(self.reviewer_root)} + ): + episodes = reviewer_lineage.reviewer_evidence( + REPO, PR, authorities=(AUTHORITY,), fetch_comments=lambda: comments + ) + decision = reviewer_lineage.reviewer_admission( + "claude", + repo=REPO, + pr_number=PR, + pr_meta=pr_meta(), + head_sha=TAKEN, + episodes=episodes, + identity=IDENTITY, + ) + self.assertFalse(decision["admitted"]) + self.assertEqual(decision["reason"], "lineage_conflict") + + def test_stale_published_evidence_waits_rather_than_guessing(self): + comments = self._publish_from_the_permanent_past() + decision = reviewer_lineage.reviewer_admission( + "claude", + repo=REPO, + pr_number=PR, + pr_meta=pr_meta(head=FIXED), + head_sha=FIXED, + episodes=comments, + identity=IDENTITY, + ) + self.assertFalse(decision["admitted"]) + self.assertEqual(decision["reason"], "lineage_waiting") + + def _publish_from_the_permanent_past(self): + return (takeover_episode(),) + + +class BoundedIdenticalReplay(unittest.TestCase): + """Republishing the same chain must not look like a malformed lineage. + + The producer publishes the whole chain on every round, and a reviewer merges + its private record with every trusted marker it finds. Eight snapshots of an + eight-episode lineage is thirty-six arrivals of thirty-two-or-fewer distinct + episodes; counting arrivals against the lineage bound calls an authorised + replay malformed. + """ + + def _chain(self, length: int): + episodes = [takeover_episode(resulting="0" * 39 + "1")] + for index in range(2, length + 1): + episodes.append( + continuation_episode( + sequence=index, + expected=episodes[-1].resulting_head, + resulting=f"{index:040x}", + ) + ) + return tuple(episodes) + + def test_eight_snapshots_of_an_eight_episode_chain_still_resolve(self): + chain = self._chain(8) + arrivals = tuple(episode for _ in range(8) for episode in chain) + self.assertGreater(len(arrivals), builder_lineage.MAX_EPISODES) + + resolved = builder_lineage.resolve_lineage( + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=chain[-1].resulting_head, + episodes=arrivals, + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.episodes, 8) + + def test_repeated_markers_collapse_before_they_reach_the_resolver(self): + root = git_free_tempdir(self, "code-mower-replay-") + chain = self._chain(8) + comments = [published(chain) for _ in range(8)] + with mock.patch.dict( + "os.environ", {lane_handoff.STATE_DIR_ENV: str(root)} + ): + episodes = reviewer_lineage.reviewer_evidence( + REPO, PR, authorities=(AUTHORITY,), fetch_comments=lambda: comments + ) + self.assertEqual(len(episodes), 8) + + def test_a_disagreeing_duplicate_still_fails_closed(self): + chain = self._chain(3) + forged = _variant(chain[1], destination_lane="claude", source_lane="claude") + resolved = builder_lineage.resolve_lineage( + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=chain[-1].resulting_head, + episodes=chain + chain + (forged,), + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_duplicated") + + def test_an_unbounded_arrival_is_refused_without_being_walked(self): + chain = self._chain(2) + arrivals = chain * (builder_lineage.MAX_EPISODE_ENTRIES // 2 + 1) + resolved = builder_lineage.resolve_lineage( + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=chain[-1].resulting_head, + episodes=arrivals, + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_malformed") + + +class AReviewerMustBeAbleToNameItsOwnLane(unittest.TestCase): + """A floor, not a default. + + Reviewer independence is decided by naming lanes. A deployment whose + contract maps the reviewer's own label or account to a blank string names + no lane for it, so it cannot be recognised as the contributor it is -- and + the seam admits exactly the reviewer it exists to exclude. `setdefault` + left such an entry in place, because the key was present. + """ + + MINIMAL = { + "enabled": True, + "labels": {"builder:codex": ""}, + "authors": {"codex[bot]": ""}, + } + + def _admit(self, lane, identity, *, author="codex[bot]", + labels=("builder:codex",)): + from code_mower import codex_audit_pr + + with mock.patch.dict( + "os.environ", + {reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(identity)}, + ): + return codex_audit_pr._require_independent_review( + lane, REPO, PR, pr_meta(author=author, labels=labels), TAKEN, + authorities=(), fetch_comments=lambda: [], + ) + + def test_a_blank_own_label_and_account_still_exclude_codex(self): + with self.assertRaises(RuntimeError) as raised: + self._admit("codex", self.MINIMAL) + self.assertIn("contributor_not_independent", str(raised.exception)) + + def test_an_unrelated_reviewer_is_unaffected_by_its_own_blank_entry(self): + """Only the reviewer's own lane is floored; Claude still reviews.""" + + decision = self._admit("claude", self.MINIMAL) + self.assertTrue(decision["admitted"]) + + def test_a_missing_contract_still_excludes_the_reviewer(self): + with self.assertRaises(RuntimeError) as raised: + self._admit("codex", {"enabled": False, "labels": {}, "authors": {}}) + self.assertIn("contributor_not_independent", str(raised.exception)) + + def test_an_invalid_own_mapping_is_overwritten_not_preserved(self): + for invalid in (None, 0, [], {}, " "): + with self.subTest(invalid=invalid): + identity = { + "enabled": True, + "labels": {"builder:codex": invalid}, + "authors": {"codex[bot]": invalid}, + } + with self.assertRaises(RuntimeError) as raised: + self._admit("codex", identity) + self.assertIn("contributor_not_independent", str(raised.exception)) + + def test_a_conflicting_own_label_refuses_before_the_provider_runs(self): + identity = { + "enabled": True, + "labels": {"builder:codex": "claude"}, + "authors": {"codex[bot]": "codex"}, + } + with self.assertRaises(reviewer_lineage.ReviewerIdentityInvalid) as raised: + self._admit("codex", identity) + self.assertIn("reviewer_identity_invalid", str(raised.exception)) + + def test_a_conflicting_own_account_refuses_before_the_provider_runs(self): + identity = { + "enabled": True, + "labels": {"builder:codex": "codex"}, + "authors": {"codex[bot]": "devin"}, + } + with self.assertRaises(reviewer_lineage.ReviewerIdentityInvalid): + self._admit("codex", identity) + + def test_a_disabled_contract_with_a_conflict_still_refuses(self): + """Disabling the contract does not make a misnamed own lane safe.""" + + identity = { + "enabled": False, + "labels": {"builder:codex": "claude"}, + "authors": {}, + } + with self.assertRaises(reviewer_lineage.ReviewerIdentityInvalid): + self._admit("codex", identity) + + +class ConfiguredBranchIdentityIsCounted(unittest.TestCase): + """`branch_prefixes` was rendered into the contract and then ignored. + + A `codex/` branch carrying a `builder:claude` label is two configured + signals disagreeing about who wrote the diff. Resolving it to a sole + Claude writer admits Codex to review its own work. + """ + + CONFIG = { + "enabled": True, + "labels": {"builder:claude": "claude", "builder:codex": "codex"}, + "authors": {"claude[bot]": "claude", "codex[bot]": "codex"}, + "branch_prefixes": {"claude/": "claude", "codex/": "codex"}, + "require_verified_lineage": True, + } + UNCONFIGURED = { + "enabled": True, + "labels": {"builder:claude": "claude", "builder:codex": "codex"}, + "authors": {}, + } + + def _gate(self, *, branch, labels, config=None, episodes=()): + return resolve_builder_lineage( + labels=list(labels), + author="a-human", + config=config or self.CONFIG, + repo=REPO, + pr_number=PR, + branch=branch, + head_sha=TAKEN, + episodes=episodes, + ) + + def test_a_branch_and_label_disagreement_requires_verified_lineage(self): + resolved = self._gate(branch="codex/topic", labels=["builder:claude"]) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "conflicting_builder_identity") + + def test_a_matching_branch_and_label_stay_ordinary(self): + resolved = self._gate(branch="claude/topic", labels=["builder:claude"]) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.current_writer, "claude") + + def test_an_unconfigured_deployment_keeps_its_old_answer(self): + resolved = self._gate( + branch="codex/topic", labels=["builder:claude"], config=self.UNCONFIGURED + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.current_writer, "claude") + + def test_a_recorded_takeover_is_still_accepted_over_the_branch(self): + """Verified episodes decide; the branch never invents a takeover.""" + + resolved = self._gate( + branch=BRANCH, labels=["builder:codex"], episodes=(takeover_episode(),) + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.current_writer, "codex") + + def test_the_wrapper_refuses_the_disagreement_rather_than_admitting(self): + decision = reviewer_lineage.reviewer_admission( + "codex", + repo=REPO, + pr_number=PR, + pr_meta=pr_meta(author="a-human", labels=("builder:claude",), + branch="codex/topic"), + head_sha=TAKEN, + episodes=(), + identity=self.CONFIG, + ) + self.assertFalse(decision["admitted"]) + + +class TheWrapperCompositionKeepsTheConfiguredBranchContract(unittest.TestCase): + """The floor must raise three fields, not rebuild the contract. + + Every real wrapper resolves through ``identity_with_lane_floor``. It was + reconstructing the mapping from ``enabled``/``labels``/``authors`` alone, + so ``branch_prefixes`` and ``require_verified_lineage`` -- rendered into + the contract for exactly this decision -- never reached the resolver. The + lower-helper branch tests pass an already-resolved identity and so walk + straight past the composition that drops it. These load the configured + contract from the environment and go through the real admission boundary. + """ + + CONFIG = { + "enabled": True, + "labels": {"builder:claude": "claude", "builder:codex": "codex", + "builder:devin": "devin"}, + "authors": {"claude[bot]": "claude", "codex[bot]": "codex", + "devin-ai-integration[bot]": "devin"}, + "branch_prefixes": {"claude/": "claude", "codex/": "codex", + "feature/cx-": "codex"}, + "require_verified_lineage": True, + } + + @contextmanager + def _configured(self): + with mock.patch.dict( + "os.environ", + { + reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(self.CONFIG), + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + }, + ): + yield + + def _codex(self, lane, meta, *, comments=()): + from code_mower import codex_audit_pr + + with self._configured(): + return codex_audit_pr._require_independent_review( + lane, REPO, PR, meta, TAKEN, + authorities=(AUTHORITY,), fetch_comments=lambda: list(comments), + ) + + def _claude(self, lane, meta, *, comments=()): + from code_mower import claude_audit_pr + + with self._configured(): + return claude_audit_pr._require_independent_review( + lane, REPO, PR, meta, TAKEN, + authorities=(AUTHORITY,), fetch_comments=lambda: list(comments), + ) + + def _devin(self, meta, author, *, comments=()): + from code_mower import devin_cli_audit_pr + + config = SimpleNamespace(repo=REPO, pr_number=PR, github_token="unused") + with self._configured(): + return devin_cli_audit_pr._require_independent_devin_review( + config, meta, TAKEN, author, fetch_comments=lambda: list(comments), + ) + + DISAGREEING = dict(author="a-human", labels=("builder:claude",), + branch="codex/topic") + + def test_the_codex_wrapper_stops_before_the_provider_runs(self): + with self.assertRaises(RuntimeError) as raised: + self._codex("codex", pr_meta(**self.DISAGREEING)) + self.assertIn("lineage", str(raised.exception).lower()) + + def test_the_claude_wrapper_stops_on_the_same_disagreement(self): + # Asked about `codex`, which the unfixed composition admits outright: + # a sole-Claude answer makes Codex look independent of its own branch. + with self.assertRaises(RuntimeError) as raised: + self._claude("codex", pr_meta(**self.DISAGREEING)) + self.assertNotIn("contributor_not_independent", str(raised.exception)) + + def test_the_devin_wrapper_stops_on_the_same_disagreement(self): + from code_mower import devin_cli_audit_pr + + with self.assertRaises( + (devin_cli_audit_pr.AuthorExcludedError, RuntimeError) + ): + self._devin(pr_meta(**self.DISAGREEING), "a-human") + + def test_a_custom_configured_branch_prefix_is_honoured(self): + with self.assertRaises(RuntimeError): + self._codex( + "codex", + pr_meta(author="a-human", labels=("builder:claude",), + branch="feature/cx-topic"), + ) + + def test_a_matched_branch_and_label_keep_their_intended_behaviour(self): + matched = pr_meta(author="a-human", labels=("builder:claude",), + branch="claude/topic") + decision = self._codex("codex", matched) + self.assertTrue(decision["admitted"]) + with self.assertRaises(RuntimeError) as raised: + self._claude("claude", matched) + self.assertIn("contributor_not_independent", str(raised.exception)) + + def test_a_recorded_cross_lane_takeover_is_accepted_and_excludes_both(self): + comments = [published([takeover_episode()])] + taken = pr_meta(author="devin-ai-integration[bot]", + labels=("builder:codex",), branch=BRANCH) + decision = self._claude("claude", taken, comments=comments) + self.assertTrue(decision["admitted"]) + self.assertEqual(decision["current_writer"], "codex") + for lane in ("codex", "devin"): + with self.subTest(lane=lane): + with self.assertRaises(RuntimeError): + self._claude(lane, taken, comments=comments) + + def test_the_floor_still_refuses_a_conflicting_own_remap(self): + conflicting = dict(self.CONFIG) + conflicting["labels"] = dict(self.CONFIG["labels"]) + conflicting["labels"]["builder:codex"] = "claude" + with mock.patch.dict( + "os.environ", + {reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(conflicting)}, + ): + with self.assertRaises(reviewer_lineage.ReviewerIdentityInvalid): + reviewer_lineage.identity_with_lane_floor( + reviewer_lineage.load_identity(), "codex" + ) + + def _aliased(self, *pairs): + contract = dict(self.CONFIG) + contract["authors"] = dict(pairs) + return contract + + def test_a_conflicting_account_alias_refuses_in_either_order(self): + """Account names match case-insensitively, so these are one account. + + Composing the floor over the raw key left the alias untouched beside a + new canonical entry, and which one survived normalisation came down to + insertion order -- an alias could outrank the canonical account and let + a lane review its own diff. + """ + + orders = ( + (("Codex[Bot]", "claude"), ("codex[bot]", "codex")), + (("codex[bot]", "codex"), ("Codex[Bot]", "claude")), + ((" codex[bot] ", "claude"), ("codex[bot]", "codex")), + (("CODEX[BOT]", "devin"), ("codex[bot]", "codex")), + ) + for pairs in orders: + with self.subTest(order=pairs): + with mock.patch.dict( + "os.environ", + {reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps( + self._aliased(*pairs) + )}, + ): + with self.assertRaises( + reviewer_lineage.ReviewerIdentityInvalid + ): + reviewer_lineage.identity_with_lane_floor( + reviewer_lineage.load_identity(), "codex" + ) + + def test_no_provider_is_invoked_on_a_conflicting_alias(self): + """The wrappers refuse during composition, before any provider runs.""" + + contract = self._aliased(("Codex[Bot]", "claude"), ("codex[bot]", "codex")) + with mock.patch.dict( + "os.environ", + { + reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(contract), + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + }, + ): + for wrapper in ("codex_audit_pr", "claude_audit_pr"): + with self.subTest(wrapper=wrapper): + module = __import__( + f"code_mower.{wrapper}", fromlist=["_require_independent_review"] + ) + with self.assertRaises( + reviewer_lineage.ReviewerIdentityInvalid + ): + module._require_independent_review( + "codex", REPO, PR, pr_meta(), TAKEN, + authorities=(AUTHORITY,), fetch_comments=lambda: [], + ) + from code_mower import devin_cli_audit_pr + + config = SimpleNamespace(repo=REPO, pr_number=PR, github_token="unused") + with self.assertRaises(reviewer_lineage.ReviewerIdentityInvalid): + devin_cli_audit_pr._require_independent_devin_review( + config, pr_meta(), TAKEN, "a-human", fetch_comments=lambda: [], + ) + + def test_a_compatible_account_alias_is_accepted(self): + """Two spellings that name the same lane are one account, not a clash.""" + + for pairs in ( + (("Codex[Bot]", "codex"), ("codex[bot]", "codex")), + (("codex[bot]", "codex"), ("CODEX[BOT]", "Codex")), + ((" codex[bot] ", "codex"),), + ): + with self.subTest(order=pairs): + floored = reviewer_lineage.identity_with_lane_floor( + self._aliased(*pairs), "codex" + ) + self.assertEqual(floored["authors"]["codex[bot]"], "codex") + self.assertEqual( + floored["branch_prefixes"], self.CONFIG["branch_prefixes"] + ) + self.assertTrue(floored["require_verified_lineage"]) + + def test_an_alias_cannot_outrank_the_canonical_account(self): + """Whatever the alias said, the canonical account names its own lane.""" + + floored = reviewer_lineage.identity_with_lane_floor( + self._aliased(("Codex[Bot]", "codex"), ("devin-ai-integration[bot]", "devin")), + "codex", + ) + self.assertEqual(floored["authors"]["codex[bot]"], "codex") + self.assertEqual(floored["labels"]["builder:codex"], "codex") + + def test_the_floor_carries_the_branch_contract_through(self): + floored = reviewer_lineage.identity_with_lane_floor(self.CONFIG, "codex") + self.assertEqual(floored["branch_prefixes"], self.CONFIG["branch_prefixes"]) + self.assertTrue(floored["require_verified_lineage"]) + self.assertEqual(floored["labels"]["builder:codex"], "codex") + + +class ATrustedMarkerMustParseOrSaySo(unittest.TestCase): + """A broken marker is unreadable evidence, never absent evidence. + + The payload regex matches only a complete, object-shaped, terminated + marker. Looking for evidence with it alone meant an unterminated or + non-object marker was not read as broken -- it was not seen at all, and a + trusted comment announcing lineage reported none. Absence and + unreadability are opposite answers: absence admits an independent reviewer + on the ordinary single-builder story, unreadability has to stop. + """ + + TRUST = ("codemower-ai",) + + def _trusted(self, body, author=AUTHORITY): + return [{"user": {"login": author}, "body": body}] + + def _gate(self, comments): + """What the gate and the labelers actually run over the comments.""" + + return published_lineage_episodes( + comments, + trusted_author=lineage_marker_author_trust(authorities=self.TRUST), + ) + + def _labeler(self, comments): + """The labeler entrypoint, which fails closed on LineageError.""" + + return lineage_context( + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + comments=comments, + trusted_author=lineage_marker_author_trust(authorities=self.TRUST), + ) + + def _valid(self): + return builder_lineage.lineage_comment_marker((takeover_episode(),)) + + def _assert_unreadable(self, body): + comments = self._trusted(body) + for consumer in (self._gate, self._labeler): + with self.subTest(consumer=consumer.__name__): + with self.assertRaises(builder_lineage.LineageError): + consumer(comments) + + def test_a_well_formed_marker_still_reads(self): + episodes = self._gate(self._trusted(self._valid())) + self.assertEqual(len(episodes), 1) + self.assertEqual(self._labeler(self._trusted(self._valid())).episodes, + episodes) + + def test_an_unterminated_marker_is_unreadable_not_absent(self): + self._assert_unreadable(self._valid().replace("-->", "")) + + def test_a_non_object_payload_is_unreadable(self): + self._assert_unreadable( + f"" + ) + + def test_malformed_json_is_unreadable(self): + self._assert_unreadable( + f'' + ) + + def test_an_empty_payload_is_unreadable(self): + self._assert_unreadable(f"") + + def test_two_markers_on_one_comment_are_ambiguous(self): + self._assert_unreadable(f"{self._valid()}\n\n{self._valid()}") + + def test_a_valid_marker_beside_a_broken_one_is_still_ambiguous(self): + broken = f"" + self._assert_unreadable(f"{self._valid()}\n\n{broken}") + + def test_a_marker_past_the_body_bound_is_unreadable_not_absent(self): + filler = "x" * builder_lineage.MAX_MARKER_BODY_CHARS + self._assert_unreadable(filler + "\n" + self._valid()) + + def test_an_untrusted_broken_marker_is_not_authoritative(self): + """Trust is decided before parsing, so an outsider cannot force a stop.""" + + body = self._valid().replace("-->", "") + comments = self._trusted(body, author=OUTSIDER) + self.assertEqual(self._gate(comments), ()) + self.assertEqual(self._labeler(comments).episodes, ()) + + def test_unrelated_comments_are_ignored(self): + comments = self._trusted("Looks good to me. Shipping after CI.") + self.assertEqual(self._gate(comments), ()) + + def test_a_mixed_history_stops_on_the_broken_comment(self): + comments = self._trusted(self._valid()) + self._trusted( + self._valid().replace("-->", "") + ) + with self.assertRaises(builder_lineage.LineageError): + self._gate(comments) + + def _duplicated(self, key: str, extra: str) -> str: + """The valid marker with ``key`` named a second time.""" + + marker = self._valid() + head, _, tail = marker.partition("{") + return f'{head}{{{json.dumps(key)}:{extra},{tail}' + + def test_a_duplicate_top_level_key_is_unreadable(self): + for key, extra in ( + ("schema", '"code_mower.builderLineage.v1"'), + ("schema", '"something.else"'), + ("episodes", "[]"), + ): + with self.subTest(key=key, extra=extra): + self._assert_unreadable(self._duplicated(key, extra)) + + def test_a_duplicate_key_inside_an_episode_is_unreadable(self): + marker = self._valid() + # Name the episode's own binding twice: two answers to "which head". + forged = marker.replace( + '"resulting_head"', '"resulting_head":"' + "c" * 40 + '","resulting_head"', 1 + ) + self.assertNotEqual(forged, marker) + self._assert_unreadable(forged) + + def test_a_duplicate_nested_identity_key_is_unreadable(self): + marker = self._valid() + forged = marker.replace( + '"destination_lane"', '"destination_lane":"claude","destination_lane"', 1 + ) + self.assertNotEqual(forged, marker) + self._assert_unreadable(forged) + + def test_a_unique_key_payload_still_reads(self): + self.assertEqual(len(self._gate(self._trusted(self._valid()))), 1) + + def test_an_untrusted_duplicate_key_marker_is_not_authoritative(self): + comments = self._trusted(self._duplicated("episodes", "[]"), author=OUTSIDER) + self.assertEqual(self._gate(comments), ()) + self.assertEqual(self._labeler(comments).episodes, ()) + + def test_a_duplicate_key_marker_admits_no_reviewer(self): + """The wrapper boundary: unreadable evidence stops, never admits.""" + + comments = self._trusted(self._duplicated("episodes", "[]")) + with self.assertRaises(RuntimeError) as raised: + from code_mower import claude_audit_pr + + claude_audit_pr._require_independent_review( + "claude", REPO, PR, pr_meta(), TAKEN, + authorities=(AUTHORITY,), fetch_comments=lambda: comments, + ) + self.assertIn("lineage_unreadable", str(raised.exception)) + + def test_a_valid_history_beside_unrelated_comments_still_resolves(self): + comments = ( + self._trusted("first pass looks reasonable") + + self._trusted(self._valid()) + + self._trusted("thanks!", author=OUTSIDER) + ) + resolved = resolve_builder_lineage( + labels=["builder:codex"], + author="devin-ai-integration[bot]", + config=IDENTITY, + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + episodes=self._gate(comments), + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.current_writer, "codex") + + +class CumulativePublicationAtFullLength(unittest.TestCase): + """The longest supported lineage, published the way the producer publishes. + + Evidence goes out as a cumulative snapshot after every round, so a lineage + that runs to ``MAX_EPISODES`` is delivered as ``1 + 2 + ... + 32`` raw + episodes. A bound below that total refuses a lineage the system is + documented to support -- and refuses it before deduplication, the only step + that could have shown those arrivals to be one chain. + """ + + def _chain(self, length: int): + episodes = [takeover_episode(resulting="0" * 39 + "1")] + for index in range(2, length + 1): + episodes.append( + continuation_episode( + sequence=index, + expected=episodes[-1].resulting_head, + resulting=f"{index:040x}", + ) + ) + return tuple(episodes) + + def _cumulative_comments(self, chain): + """One published comment per round, each carrying the whole chain.""" + + return [published(chain[:length]) for length in range(1, len(chain) + 1)] + + def _gate_episodes(self, comments): + """What the gate and the labelers actually read off the comments.""" + + return published_lineage_episodes( + comments, + trusted_author=lineage_marker_author_trust(authorities=(AUTHORITY,)), + ) + + def test_the_full_cumulative_history_is_the_documented_arrival_maximum(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + expected = builder_lineage.MAX_EPISODES * (builder_lineage.MAX_EPISODES + 1) // 2 + self.assertEqual(len(arrivals), expected, "1 + 2 + ... + 32") + self.assertEqual(expected, 528) + self.assertLessEqual(expected, builder_lineage.MAX_EPISODE_ARRIVALS) + + def test_the_gate_resolves_the_current_head_from_the_full_history(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + lineage = resolve_builder_lineage( + labels=["builder:codex"], + author="devin-ai-integration[bot]", + config=IDENTITY, + repo=REPO, + pr_number=PR, + branch=BRANCH, + head_sha=chain[-1].resulting_head, + episodes=arrivals, + ) + self.assertEqual(lineage.status, "resolved") + self.assertEqual(lineage.episodes, builder_lineage.MAX_EPISODES) + self.assertEqual(lineage.current_writer, "codex") + + def test_a_reviewer_overlapping_its_private_record_still_resolves(self): + """The completed chain arrives once more from the local store.""" + + root = git_free_tempdir(self, "code-mower-cumulative-") + chain = self._chain(builder_lineage.MAX_EPISODES) + for episode in chain: + builder_lineage.record_episode(lane_handoff.lineage_root(root), episode) + comments = self._cumulative_comments(chain) + with mock.patch.dict("os.environ", {lane_handoff.STATE_DIR_ENV: str(root)}): + arrivals = reviewer_lineage.reviewer_evidence( + REPO, PR, authorities=(AUTHORITY,), fetch_comments=lambda: comments + ) + # The reviewer path merges the two stores and collapses them itself. + self.assertEqual(len(arrivals), builder_lineage.MAX_EPISODES) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, + head_sha=chain[-1].resulting_head, episodes=arrivals, + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.episodes, builder_lineage.MAX_EPISODES) + + def test_the_raw_public_and_private_union_resolves_at_the_bound(self): + """A consumer that collapses nothing hands over the exact maximum.""" + + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + chain + self.assertEqual(len(arrivals), builder_lineage.MAX_EPISODE_ARRIVALS) + self.assertEqual(len(arrivals), 560) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, + head_sha=chain[-1].resulting_head, episodes=arrivals, + ) + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.episodes, builder_lineage.MAX_EPISODES) + self.assertEqual(resolved.current_writer, "codex") + + def test_one_arrival_past_the_contract_is_still_refused(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + chain + arrivals = arrivals + (chain[-1],) + self.assertEqual(len(arrivals), builder_lineage.MAX_EPISODE_ARRIVALS + 1) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, + head_sha=chain[-1].resulting_head, episodes=arrivals, + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_malformed") + + def test_a_disagreeing_duplicate_inside_the_full_history_fails_closed(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + forged = _variant(chain[4], destination_lane="claude", source_lane="claude") + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, + head_sha=chain[-1].resulting_head, episodes=arrivals + (forged,), + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_duplicated") + + def test_a_stale_full_history_waits_rather_than_resolving(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch=BRANCH, head_sha=FIXED, episodes=arrivals, + ) + self.assertEqual(resolved.status, "waiting") + self.assertEqual(resolved.reason, "lineage_behind_head") + + def test_a_full_history_bound_to_another_branch_fails_closed(self): + chain = self._chain(builder_lineage.MAX_EPISODES) + arrivals = self._gate_episodes(self._cumulative_comments(chain)) + resolved = builder_lineage.resolve_lineage( + repo=REPO, pr_number=PR, branch="codex/959-other", + head_sha=chain[-1].resulting_head, episodes=arrivals, + ) + self.assertEqual(resolved.status, "conflict") + self.assertEqual(resolved.reason, "episode_unbound") + + def test_a_sequence_past_the_lineage_bound_never_constructs(self): + """The distinct-episode bound is enforced per entry, as it arrives.""" + + chain = self._chain(builder_lineage.MAX_EPISODES) + with self.assertRaises(builder_lineage.LineageError): + _variant(chain[-1], sequence=builder_lineage.MAX_EPISODES + 1) + + +class LaneStatusProjection(unittest.TestCase): + """The Board/controller projection reads the same evidence as the gate.""" + + def setUp(self): + self.root = git_free_tempdir(self, "code-mower-status-") + patched = mock.patch.dict( + "os.environ", + { + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + reviewer_lineage.AUTHOR_EXCLUSION_ENV: json.dumps(IDENTITY), + lane_handoff.STATE_DIR_ENV: str(self.root), + }, + ) + patched.start() + self.addCleanup(patched.stop) + + def test_the_configured_store_is_read_not_the_packaged_default(self): + from code_mower import lane_status + + builder_lineage.record_episode( + lane_handoff.lineage_root(self.root), takeover_episode() + ) + projection = lane_status.builder_lineage_for( + REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + labels=["builder:codex"], + author="devin-ai-integration[bot]", + ) + self.assertEqual(projection["status"], "resolved") + self.assertEqual(projection["current_writer"], "codex") + + def test_a_cross_host_projection_resolves_from_published_comments(self): + """No local record at all -- the Board still sees the takeover.""" + + from code_mower import lane_status + + projection = lane_status.builder_lineage_for( + REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + labels=["builder:codex"], + author="devin-ai-integration[bot]", + comments=[published([takeover_episode()])], + ) + self.assertEqual(projection["status"], "resolved") + self.assertEqual(sorted(projection["contributors"]), ["codex", "devin"]) + + def test_an_untrusted_marker_is_not_read_into_the_projection(self): + from code_mower import lane_status + + projection = lane_status.builder_lineage_for( + REPO, + pr_number=PR, + branch=BRANCH, + head_sha=TAKEN, + labels=[], + author="devin-ai-integration[bot]", + comments=[published([takeover_episode()], author=OUTSIDER)], + ) + self.assertEqual(projection["contributors"], ["devin"]) + + def test_the_status_command_asks_github_for_the_comments(self): + from code_mower import lane_status + + requested = [] + + def runner(args): + requested.append(args) + return [] + + lane_status._remote(REPO, runner, __import__("datetime").datetime.now( + __import__("datetime").UTC + ), 5, 5, 30) + self.assertTrue( + any("comments" in str(arg) for args in requested for arg in args), + "the pull request query must request published lineage comments", + ) + + +class ControllerDiagnostics(unittest.TestCase): + """Unreadable evidence and an empty reviewer set are different problems. + + Both stop the controller, and both are fail-closed, but they send the owner + to different repairs: one to re-record a broken lineage, one to configure a + reviewer lane that did not write this diff. Collapsing the second into the + first asks the owner to fix a record that is already correct. + """ + + def _report(self, config, pr): + from code_mower import controller + + return controller.evaluate_controller_report( + status_report=_status([pr]), + ready_issues={"available": True, "errors": [], "issues": []}, + config=config, + options=_options("manual"), + )["decision"] + + def test_a_resolved_lineage_with_no_independent_reviewer_names_the_lanes(self): + pr = _pr(builder="builder:codex", needs=["needs-codex-audit"]) + pr["builder_lineage"] = { + "status": "resolved", + "contributors": ["devin", "codex"], + "current_writer": "codex", + "owner_action": "", + } + decision = self._report(_only_codex_merge_reviewer(), pr) + + self.assertEqual(decision["decision_state"], "owner_action") + self.assertEqual(decision["owner_action_kind"], "reviewer_lanes_missing") + self.assertEqual(decision["stop_condition"], "reviewer_lanes_missing") + self.assertEqual(decision["builder_lineage_status"], "resolved") + self.assertEqual(sorted(decision["builder_contributors"]), ["codex", "devin"]) + + def test_unresolved_lineage_still_reports_the_lineage_repair(self): + pr = _pr(builder="builder:codex", needs=["needs-codex-audit"]) + pr["builder_lineage"] = { + "status": "conflict", + "contributors": [], + "current_writer": "", + "owner_action": "re-record the lineage from the verified handoff", + } + decision = self._report(_only_codex_merge_reviewer(), pr) + + self.assertEqual(decision["owner_action_kind"], "builder_lineage") + self.assertEqual(decision["stop_condition"], "builder_lineage_unresolved") + self.assertEqual( + decision["next_detail"], "re-record the lineage from the verified handoff" + ) + + +class AutoRecordCli(unittest.TestCase): + """The real ``code-mower builder auto-record`` path after a takeover.""" + + def setUp(self): + self.dir = git_free_tempdir(self, "code-mower-auto-record-") + patched = mock.patch.dict( + "os.environ", {"CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY} + ) + patched.start() + self.addCleanup(patched.stop) + + def _write(self, name: str, payload) -> Path: + path = self.dir / name + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + def _run(self, *, comments) -> dict: + from code_mower import builder_runs + + event = self._write( + "event.json", + { + "pull_request": { + "number": PR, + "html_url": f"https://github.com/{REPO}/pull/{PR}", + "user": {"login": "devin-ai-integration[bot]"}, + "head": {"ref": BRANCH, "sha": TAKEN}, + "labels": [{"name": "builder:codex"}], + "body": "", + }, + "repository": {"full_name": REPO}, + }, + ) + argv = [ + "auto-record", + "--pr-json", str(event), + "--repo", REPO, + "--output", str(self.dir / "run.json"), + "--force", + "--json", + ] + if comments is not None: + argv += ["--comments-json", str(self._write("comments.json", comments))] + self.assertEqual(builder_runs.main(argv), 0) + return json.loads((self.dir / "run.json").read_text(encoding="utf-8")) + + def test_a_taken_over_pull_request_is_attributed_to_the_current_writer(self): + event = self._run(comments=[published([takeover_episode()])]) + + dimensions = event["dimensions"] + self.assertEqual(event["provider"], "codex") + self.assertEqual(dimensions["builder_executor"], "chatgpt-codex-connector") + self.assertEqual(dimensions["pr_author"], "devin-ai-integration[bot]") + self.assertEqual(dimensions["builder_lineage_status"], "resolved") + self.assertEqual(dimensions["builder_current_writer"], "codex") + self.assertEqual( + sorted(dimensions["builder_contributors"]), ["codex", "devin"] + ) + self.assertIn("builder_lineage:codex", dimensions["builder_inference_signals"]) + + def test_without_published_lineage_it_records_the_unresolved_answer(self): + """The defect this replaces, kept as an explicit regression. + + Opener and label disagree and nothing explains why, so the resolver + names no writer. Auto-record still attributes to the opener, because + that is all it knows -- but the recorded lineage says so rather than + presenting the guess as settled. + """ + + event = self._run(comments=[]) + + self.assertEqual(event["provider"], "devin") + self.assertEqual(event["dimensions"]["builder_current_writer"], "") + self.assertEqual(event["dimensions"]["builder_lineage_status"], "conflict") + + def test_an_untrusted_marker_does_not_move_the_attribution(self): + event = self._run( + comments=[published([takeover_episode()], author=OUTSIDER)] + ) + + self.assertEqual(event["provider"], "devin") + self.assertNotIn( + "builder_lineage:codex", event["dimensions"]["builder_inference_signals"] + ) + + +class SaasReviewPath(unittest.TestCase): + """``pull_request_review`` resolves the same evidence as every other path.""" + + def _adapter(self): + return SimpleNamespace( + name="greptile", event_type="pull_request_review", opt_in_required=False, + label_prefix="greptile", needs_label="n", done_label="d", blocked_label="b", + supported_event_types=("pull_request_review",), + requires_review_comments=False, review_comments_page_cap=5, + check_run_done_requires_absent_same_head_review=False, + is_opted_in=lambda labels: True, is_review_author=lambda author: True, + is_check_run_author=lambda check_run: True, + is_check_run_name=lambda check_run: True, + token_env_vars=("GITHUB_TOKEN",), + ) + + def _main(self, event_path: Path, *, comments, fail: bool = False): + from code_mower import saas_reviewer_labeler as labeler + + seen = {} + + def capture(*args, **kwargs): + seen.update(kwargs) + return None, "captured" + + def fetch_comments(repo, number, *, tokens, page_cap): + if fail: + raise labeler.GitHubRequestError("GET", "/comments", 503, "") + return comments + + with mock.patch.object(labeler, "load_adapter", lambda name: self._adapter()), \ + mock.patch.object(labeler, "github_tokens_from_env", lambda *a: ("t",)), \ + mock.patch.object( + labeler, "fetch_pull_request", + lambda repo, number, **kw: { + "labels": [{"name": "builder:codex"}], + "user": {"login": "devin-ai-integration[bot]"}, + "body": "", + "head": {"sha": TAKEN, "ref": BRANCH}, + }, + ), \ + mock.patch.object(labeler, "fetch_issue_comments", fetch_comments), \ + mock.patch.object(labeler, "resolve_label_decision", capture): + code = labeler.main(["--adapter", "greptile"]) + return code, seen + + def test_the_review_path_carries_published_episodes(self): + directory = git_free_tempdir(self, "code-mower-saas-") + event_path = directory / "event.json" + event_path.write_text( + json.dumps({ + "action": "submitted", + "pull_request": {"number": PR}, + "review": {"id": 1}, + }), + encoding="utf-8", + ) + with mock.patch.dict("os.environ", { + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": REPO, + "GITHUB_EVENT_NAME": "pull_request_review", + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + }): + code, seen = self._main( + event_path, comments=[published([takeover_episode()])] + ) + + self.assertEqual(code, 0) + self.assertEqual(seen["issue_comments"], [published([takeover_episode()])]) + self.assertEqual(seen["current_head_sha"], TAKEN) + self.assertEqual(seen["head_branch"], BRANCH) + self.assertEqual(seen["decision_authorities"], (AUTHORITY,)) + + def test_an_unreadable_fetch_stops_rather_than_labelling_on_identity(self): + directory = git_free_tempdir(self, "code-mower-saas-fail-") + event_path = directory / "event.json" + event_path.write_text( + json.dumps({ + "action": "submitted", + "pull_request": {"number": PR}, + "review": {"id": 1}, + }), + encoding="utf-8", + ) + with mock.patch.dict("os.environ", { + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": REPO, + "GITHUB_EVENT_NAME": "pull_request_review", + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + }): + code, seen = self._main(event_path, comments=[], fail=True) + + self.assertEqual(code, 0) + self.assertEqual(seen, {}, "no decision is resolved on unreadable evidence") + + +class PinnedBaseSeam(unittest.TestCase): + """#963 consumes #955's already-pinned base; it never re-resolves a name.""" + + def test_the_claude_wrapper_admits_after_the_pin_and_from_config_base_ref(self): + from code_mower import claude_audit_pr + + source = Path(claude_audit_pr.__file__).read_text(encoding="utf-8") + pin = source.index("config, base_ref=diff_context.fetched_base_ref or config.base_ref") + authorities = source.index("trusted_ref=config.base_ref,", pin) + admission = source.index('_require_independent_review(\n "claude"', pin) + self.assertLess(pin, authorities) + self.assertLess(authorities, admission) + + def test_the_codex_wrapper_admits_after_its_own_pin(self): + from code_mower import codex_audit_pr + + source = Path(codex_audit_pr.__file__).read_text(encoding="utf-8") + pin = source.index("config, base_ref=_pinned_base_revision(local_repo, config.base_ref)") + authorities = source.index("trusted_ref=config.base_ref,", pin) + admission = source.index('_require_independent_review(\n "codex"', pin) + self.assertLess(pin, authorities) + self.assertLess(authorities, admission) + + def test_neither_wrapper_fetches_the_base_a_second_time_for_lineage(self): + for module_name, fetcher in ( + ("claude_audit_pr", "_fetch_base_sha_for_diff"), + ("codex_audit_pr", "_fetch_base_ref"), + ): + with self.subTest(module=module_name): + module = __import__(f"code_mower.{module_name}", fromlist=[module_name]) + source = Path(module.__file__).read_text(encoding="utf-8") + admission = source.index("authorities=decision_authorities,") + self.assertNotIn(fetcher + "(", source[admission:]) + + def test_a_ref_that_moves_after_the_fetch_does_not_change_the_authorities(self): + """The admission reads the pinned SHA, so a later push cannot rewrite it. + + Decision authorities are what makes published lineage readable, so + re-resolving a mutable name here would let a commit landing mid-audit + change which markers this reviewer trusts. + """ + + import subprocess + + from code_mower import claude_audit_pr + + repo = git_free_tempdir(self, "code-mower-pinned-base-") + + def git(*args): + subprocess.run( + ["git", *args], cwd=repo, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + git("init", "-q", "-b", "main") + git("config", "user.email", "lane@example.invalid") + git("config", "user.name", "Lane") + (repo / "code-mower.yml").write_text( + "decisions:\n authorities:\n - codemower-ai\n", encoding="utf-8" + ) + git("add", "code-mower.yml") + git("commit", "-qm", "pinned base") + pinned = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, + capture_output=True, text=True, + ).stdout.strip() + + before = claude_audit_pr._decision_authorities_for_repo( + repo, (), trusted_ref=pinned + ) + self.assertIn("codemower-ai", before) + + # The name moves under the audit; the pinned revision does not. + (repo / "code-mower.yml").write_text( + "decisions:\n authorities:\n - somebody-else\n", encoding="utf-8" + ) + git("add", "code-mower.yml") + git("commit", "-qm", "moved") + + self.assertEqual( + claude_audit_pr._decision_authorities_for_repo(repo, (), trusted_ref=pinned), + before, + ) + self.assertIn( + "somebody-else", + claude_audit_pr._decision_authorities_for_repo( + repo, (), trusted_ref="main" + ), + "the mutable name really did move, so the pin is what held", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_devin_builder_lane.py b/tests/test_devin_builder_lane.py index dbe17fa2..be84c42d 100644 --- a/tests/test_devin_builder_lane.py +++ b/tests/test_devin_builder_lane.py @@ -9,6 +9,7 @@ import subprocess import sys import tempfile +import textwrap import unittest from pathlib import Path @@ -95,13 +96,30 @@ def _lane_delivery_env() -> dict[str, str]: # $HOME/lane-delivered to model "this run opened or advanced the lane's PR"; # a fixture whose provider does not drop it models a run that delivered nothing. _DELIVERY_MARKER_NAME = "lane-delivered" +#: Every fake `gh` call is recorded, so a test can assert what was *not* done. +_GH_INVOCATION_LOG = "gh-invocations.log" +#: Present when the fixture should fail only the fresh label lookup. +_LABEL_LOOKUP_FAILS_MARKER = "labels-lookup-fails" _HEAD_BEFORE = "a" * 40 _HEAD_AFTER = "b" * 40 +# The snapshot carries the head branch from the same authenticated PR read as +# the head sha, so the fixture has to answer with both. +_HEAD_BRANCH = "devin/issue-12" _FAKE_GH_DELIVERY_HEADER = f"""#!/usr/bin/env bash set -euo pipefail cmd="${{1:-}} ${{2:-}}" args=" $* " -if [ "$cmd" = "pr list" ] && [[ "$args" == *"--json number,closingIssuesReferences,headRefName,headRefOid,headRepository,labels,author"* ]]; then +printf '%s\\n' "$*" >> "$HOME/{_GH_INVOCATION_LOG}" +if [ "$cmd" = "pr view" ] && [[ "$args" == *"--json labels"* ]]; then + # The fresh label read reconciliation depends on. A lane that cannot see the + # current labels must refuse rather than reconcile against an empty set. + if [ -f "$HOME/{_LABEL_LOOKUP_FAILS_MARKER}" ]; then + printf 'gh: could not resolve labels for this pull request\\n' >&2 + exit 1 + fi + printf '%s\\n' 'builder:devin' + exit 0 +elif [ "$cmd" = "pr list" ] && [[ "$args" == *"--json number,closingIssuesReferences,headRefName,headRefOid,headRepository,labels,author"* ]]; then if [ -f "$HOME/{_DELIVERY_MARKER_NAME}" ]; then printf '%s\\n' '[{{"number":77,"headRefName":"devin/issue-12","headRefOid":"{_HEAD_AFTER}","headRepository":{{"nameWithOwner":"owner/repo"}},"labels":[{{"name":"builder:devin"}}],"author":{{"login":"devin-ai-integration[bot]"}},"closingIssuesReferences":[{{"number":12,"repository":{{"nameWithOwner":"owner/repo"}},"url":"https://github.com/owner/repo/issues/12"}}]}}]' else @@ -111,11 +129,11 @@ def _lane_delivery_env() -> dict[str, str]: elif [ "$cmd" = "issue view" ] && [[ "$args" == *"--json labels"* ]]; then printf '%s\\n' '["tier:R","builder:devin","dispatched:devin"]' exit 0 -elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefOid,state,labels"* ]]; then +elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefName,headRefOid,state,labels"* ]]; then if [ -f "$HOME/{_DELIVERY_MARKER_NAME}" ]; then - printf '%s\\n' '{{"headRefOid":"{_HEAD_AFTER}","state":"OPEN","labels":[]}}' + printf '%s\\n' '{{"headRefName":"{_HEAD_BRANCH}","headRefOid":"{_HEAD_AFTER}","state":"OPEN","labels":[]}}' else - printf '%s\\n' '{{"headRefOid":"{_HEAD_BEFORE}","state":"OPEN","labels":[]}}' + printf '%s\\n' '{{"headRefName":"{_HEAD_BRANCH}","headRefOid":"{_HEAD_BEFORE}","state":"OPEN","labels":[]}}' fi exit 0 elif [ "$cmd" = "issue comment" ] || [ "$cmd" = "pr comment" ]; then @@ -531,6 +549,247 @@ def test_devin_lane_records_local_cli_provenance_after_pr_opens(self) -> None: self.assertIn("--pr owner/repo#77", record_argv) self.assertIn("--status pr-opened", record_argv) + def _rendered_reconcile_block(self, output_dir: Path) -> str: + """The generated runner's own label-read-then-reconcile fragment.""" + + runner = self._generated_runner(output_dir) + text = runner.read_text(encoding="utf-8") + start = text.index(" # The label set handed to reconciliation") + end = text.index(' done <<< "$reconcile_labels"') + len( + ' done <<< "$reconcile_labels"' + ) + return textwrap.dedent(text[start:end]) + + def test_devin_lane_refuses_to_reconcile_when_the_label_read_fails(self) -> None: + """A failed label read is not an empty label set. + + Reconciliation is told which labels are present so it knows which to + *remove*. Handed none, the move is purely additive: the destination + lane's label goes on, the source lane's stays, and the pull request + ends up carrying two builder labels while the run reports success. The + read is required, and required before anything is published or moved. + """ + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output_dir = root / "generated" + fragment = self._rendered_reconcile_block(output_dir) + self.assertNotIn("|| true", fragment) + + bin_dir = root / "bin" + bin_dir.mkdir() + invocations = root / "gh-invocations.log" + (bin_dir / "gh").write_text( + "#!/usr/bin/env bash\n" + f'printf "%s\\n" "$*" >> {invocations}\n' + 'if [ "${1:-} ${2:-}" = "pr view" ] ' + '&& [[ " $* " == *"--json labels"* ]]; then\n' + " printf 'gh: could not resolve labels\\n' >&2\n" + " exit 1\n" + "fi\n" + "exit 0\n", + encoding="utf-8", + ) + (bin_dir / "gh").chmod(0o755) + + harness = root / "reconcile.sh" + harness.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'LANE="devin"\n' + 'REPO="owner/repo"\n' + 'num="77"\n' + f'reconcile_head="{_HEAD_AFTER}"\n' + "reconcile_args=(lineage --publish --reconcile-labels)\n" + f'lane_delivery=("{bin_dir}/lane-delivery-must-not-run")\n' + + fragment + + "\n" + '"${lane_delivery[@]}" "${reconcile_args[@]}"\n', + encoding="utf-8", + ) + harness.chmod(0o755) + + completed = subprocess.run( + ["bash", str(harness)], + env={**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}, + text=True, + capture_output=True, + ) + attempted = [ + line + for line in invocations.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + self.assertEqual(completed.returncode, 2, completed.stderr) + self.assertIn("refusing to publish builder lineage", completed.stderr) + self.assertIn("gh pr view --json labels", completed.stderr) + # Nothing was published and no label was moved: the delivery CLI that + # would have done both was never reached. + self.assertNotIn("lane-delivery-must-not-run", completed.stderr) + self.assertEqual( + attempted, + ["pr view 77 -R owner/repo --json labels -q .labels[].name"], + "only the required read was attempted", + ) + + def test_devin_lane_reconciles_against_the_labels_it_read(self) -> None: + """The ordinary path is unchanged: observed labels are passed through.""" + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output_dir = root / "generated" + fragment = self._rendered_reconcile_block(output_dir) + + bin_dir = root / "bin" + bin_dir.mkdir() + (bin_dir / "gh").write_text( + "#!/usr/bin/env bash\n" + "printf 'builder:devin\\nneeds-codex-audit\\n'\n", + encoding="utf-8", + ) + (bin_dir / "gh").chmod(0o755) + + harness = root / "reconcile.sh" + harness.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'LANE="devin"\n' + 'REPO="owner/repo"\n' + 'num="77"\n' + f'reconcile_head="{_HEAD_AFTER}"\n' + "reconcile_args=(lineage)\n" + + fragment + + "\n" + 'printf "%s\\n" "${reconcile_args[@]}"\n', + encoding="utf-8", + ) + completed = subprocess.run( + ["bash", str(harness)], + env={**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}, + text=True, + capture_output=True, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual( + completed.stdout.split(), + ["lineage", "--label", "builder:devin", "--label", "needs-codex-audit"], + ) + + def test_devin_lane_full_runner_refuses_when_only_the_label_read_fails( + self, + ) -> None: + """The whole generated runner, with only the labels lookup failing. + + The fragment-level case proves the guard refuses; this proves the + *ordering* -- that the generated runner reaches the guard before it + publishes a lineage comment or edits a label, so a failed label read + cannot leave a pull request carrying two builder labels. + """ + + # The lane's private context store refuses to live inside a Git + # repository, and this runtime's TMPDIR is inside this checkout. That + # is a real product constraint, so the fixture is placed outside one + # rather than the constraint being relaxed. + base = Path(tempfile.gettempdir()).resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + base = Path("/tmp").resolve() + if any((parent / ".git").exists() for parent in (base, *base.parents)): + self.skipTest("no Git-free temporary directory is available here") + tmp = tempfile.mkdtemp(prefix="code-mower-full-runner-", dir=str(base)) + self.addCleanup(shutil.rmtree, tmp, True) + if True: + root = Path(tmp).resolve() + output_dir = root / "generated" + runner = self._generated_runner(output_dir) + + bin_dir = root / "bin" + bin_dir.mkdir() + work_root = root / "work" + work = work_root / "devin" / "owner__repo" + work.joinpath(".git", "hooks").mkdir(parents=True) + # Fail only the fresh label lookup; everything else answers. + (root / _LABEL_LOOKUP_FAILS_MARKER).write_text("", encoding="utf-8") + + fake_gh = bin_dir / "gh" + fake_gh.write_text( + _FAKE_GH_DELIVERY_HEADER + + """if [ "$cmd" = "pr list" ] && [[ "$args" == *"--label builder:devin"* ]]; then + printf '%s\\n' '[{"number":77,"labels":[{"name":"builder:devin"},{"name":"codex-audit-blocked"}],"updatedAt":"2026-01-01T00:00:00Z","headRepository":{"nameWithOwner":"owner/repo"},"headRefName":"devin/issue-12","author":{"login":"devin-ai-integration[bot]"}}]' +elif [ "$cmd" = "issue list" ]; then + printf '%s\\n' '[]' +elif [ "$cmd" = "pr list" ] && [[ "$args" == *"--search"* ]]; then + printf '%s\\n' '[]' +elif [ "$cmd" = "pr view" ] && [[ "$args" == *"headRepository"* ]]; then + printf '%s\\n' '{"headRefName":"devin/issue-12","headRefOid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","headRepository":{"nameWithOwner":"owner/repo"},"labels":[{"name":"builder:devin"},{"name":"codex-audit-blocked"}],"author":{"login":"devin-ai-integration[bot]"}}' +elif [ "$cmd" = "pr view" ] && [[ "$args" == *"title,body"* ]]; then + printf '%s\\n' '{"title":"Fix round","body":"Body","headRefName":"devin/issue-12","headRefOid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","url":"https://github.com/owner/repo/pull/77","labels":[{"name":"builder:devin"},{"name":"codex-audit-blocked"}],"author":{"login":"devin-ai-integration[bot]"}}' +elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json comments"* ]]; then + printf '%s\\n' '{"comments":[{"author":{"login":"owner"},"createdAt":"2026-01-01T00:00:00Z","body":"## Codex audit (merge-authority lane)\\n\\nHead SHA: `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`\\n\\nCodex Audit: BLOCKED\\n"}]}' +elif [ "$cmd" = "pr diff" ]; then + printf 'diff --git a/x b/x\\n' +elif [ "$cmd" = "repo view" ]; then + printf 'main\\n' +elif [ "$cmd" = "pr edit" ]; then + printf 'label mutation must not happen\\n' >&2 + exit 9 +elif [ "$cmd" = "issue view" ]; then + if [[ "$args" == *"--json comments"* ]]; then + printf '%s\\n' '{"comments":[{"author":{"login":"owner"},"createdAt":"2026-01-01T00:00:00Z","body":"# Work Order: Trusted task\\n\\nFix it."}]}' + else + printf '%s\\n' '{"title":"Issue 12","body":"Body","labels":[{"name":"tier:R"}],"url":"https://github.com/owner/repo/issues/12","author":{"login":"owner"}}' + fi +else + printf 'unexpected gh invocation: %s\\n' "$*" >&2 + exit 2 +fi +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + + (bin_dir / "git").write_text(_FAKE_GIT, encoding="utf-8") + (bin_dir / "git").chmod(0o755) + (bin_dir / "devin").write_text(_FAKE_DEVIN_DELIVERS, encoding="utf-8") + (bin_dir / "devin").chmod(0o755) + (bin_dir / "code-mower").write_text( + "#!/usr/bin/env bash\nexit 0\n", encoding="utf-8" + ) + (bin_dir / "code-mower").chmod(0o755) + + completed = subprocess.run( + [ + str(runner), "--lane", "devin", "--repo", "owner/repo", + "--max-minutes", "1", + ], + cwd=output_dir, + env={ + **os.environ, **_lane_delivery_env(), + "HOME": str(root), + "LANE_WORK_ROOT": str(work_root), + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + }, + text=True, + capture_output=True, + ) + invoked = (root / _GH_INVOCATION_LOG).read_text(encoding="utf-8") + + # The run must actually have reached the boundary, not stopped short + # of it: a fixture that selects nothing would otherwise "prove" the + # ordering by never exercising it. + self.assertIn("selected fix pr #77", completed.stdout.lower(), completed.stdout) + self.assertNotEqual(completed.returncode, 0, completed.stdout) + self.assertIn( + "refusing to publish builder lineage", completed.stderr, completed.stderr + ) + # Nothing was published and nothing was relabelled: the runner reached + # the required read before either write. + for forbidden in ("pr edit", "pr comment"): + self.assertNotIn( + f"\n{forbidden} ", f"\n{invoked}", f"the runner ran `gh {forbidden}`" + ) + def test_devin_lane_warns_but_still_succeeds_when_builder_record_fails(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tests/test_init_build_loop.py b/tests/test_init_build_loop.py index 183ce5b9..70fc669b 100644 --- a/tests/test_init_build_loop.py +++ b/tests/test_init_build_loop.py @@ -75,11 +75,11 @@ def _write_lane_delivery_wrapper(directory: Path) -> Path: elif [ "$cmd" = "issue view" ] && [[ "$args" == *"--json labels"* ]]; then printf '%s\\n' '["tier:R","builder:codex","dispatched:codex"]' exit 0 -elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefOid,state,labels"* ]]; then +elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefName,headRefOid,state,labels"* ]]; then if [ -f "$HOME/lane-delivered" ]; then - printf '%s\\n' '{"headRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"OPEN","labels":[]}' + printf '%s\\n' '{"headRefName":"codex/issue-12","headRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"OPEN","labels":[]}' else - printf '%s\\n' '{"headRefOid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state":"OPEN","labels":[]}' + printf '%s\\n' '{"headRefName":"codex/issue-12","headRefOid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state":"OPEN","labels":[]}' fi exit 0 elif [ "$cmd" = "issue comment" ] || [ "$cmd" = "pr comment" ]; then @@ -1199,8 +1199,8 @@ def test_mac_lane_runner_extra_flags_unset_or_empty_reach_provider(self) -> None elif [ "$cmd" = "issue view" ] && [[ "$args" == *"--json labels"* ]]; then printf '%s\\n' '["tier:R","builder:{lane}","dispatched:{lane}"]' exit 0 -elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefOid,state,labels"* ]]; then - printf '%s\\n' '{{"headRefOid":"{head_oid}","state":"OPEN","labels":[]}}' +elif [ "$cmd" = "pr view" ] && [[ "$args" == *"--json headRefName,headRefOid,state,labels"* ]]; then + printf '%s\\n' '{{"headRefName":"{lane}/issue-12","headRefOid":"{head_oid}","state":"OPEN","labels":[]}}' exit 0 elif [ "$cmd" = "pr list" ] && [[ "$args" == *"--label builder:{lane}"* ]]; then printf '%s\\n' '[]' diff --git a/tests/test_lane_status_branch_contract.py b/tests/test_lane_status_branch_contract.py new file mode 100644 index 00000000..c8550275 --- /dev/null +++ b/tests/test_lane_status_branch_contract.py @@ -0,0 +1,169 @@ +"""Status routes reviewers, so it resolves under the gate's own contract. + +Without the configured branch identity this projection called a `codex/` +branch labelled `builder:claude` a sole Claude writer -- and would route a +reviewer on that -- while the gate refused the very same pull request. These +go through the actual status consumer, not a lower helper. +""" + +from __future__ import annotations + +import json +import os +import sys +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from code_mower import lane_status # noqa: E402 + +CONFIG = { + "enabled": True, + "labels": {"builder:claude": "claude", "builder:codex": "codex"}, + "authors": {"claude[bot]": "claude", "codex[bot]": "codex", + "devin-ai-integration[bot]": "devin"}, + "branch_prefixes": {"claude/": "claude", "codex/": "codex", + "feature/cx-": "codex"}, + "require_verified_lineage": True, +} +UNCONFIGURED = { + "enabled": True, + "labels": {"builder:claude": "claude", "builder:codex": "codex"}, + "authors": {}, +} +HEAD = "b" * 40 +BEFORE = "a" * 40 +AUTHORITY = "owner" + + +def _handoff_marker(branch: str) -> str: + from code_mower import builder_lineage + + episode = builder_lineage.ContributionEpisode( + sequence=1, + kind=builder_lineage.HANDOFF_KIND, + repo="owner/repo", + pr_number=7, + branch=branch, + source_lane="devin", + destination_lane="codex", + expected_head=BEFORE, + resulting_head=HEAD, + writer_state="terminated", + ) + return "Lineage\n\n" + builder_lineage.lineage_comment_marker((episode,)) + + +class StatusCarriesTheConfiguredBranchContract(unittest.TestCase): + def _lineage(self, *, branch, labels, config, comments=None, + author="a-human"): + env = { + "CODE_MOWER_AUTHOR_EXCLUSION_JSON": json.dumps(config), + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + } + with mock.patch.dict(os.environ, env, clear=False): + return lane_status.builder_lineage_for( + "owner/repo", + pr_number=7, + branch=branch, + head_sha=HEAD, + labels=list(labels), + author=author, + raw_comments=[] if comments is None else comments, + ) + + def test_a_matched_ordinary_branch_resolves(self): + result = self._lineage( + branch="claude/topic", labels=["builder:claude"], config=CONFIG + ) + self.assertEqual(result["status"], "resolved") + self.assertEqual(result["current_writer"], "claude") + + def test_a_custom_configured_prefix_resolves(self): + result = self._lineage( + branch="feature/cx-topic", labels=["builder:codex"], config=CONFIG + ) + self.assertEqual(result["status"], "resolved") + self.assertEqual(result["current_writer"], "codex") + + def test_a_configured_branch_and_label_conflict_refuses(self): + result = self._lineage( + branch="codex/topic", labels=["builder:claude"], config=CONFIG + ) + self.assertEqual(result["status"], "conflict") + self.assertEqual(result["reason"], "conflicting_builder_identity") + self.assertEqual( + result["current_writer"], "", "an unresolved lineage names no writer" + ) + + def test_no_configured_branch_contract_keeps_the_old_answer(self): + result = self._lineage( + branch="codex/topic", labels=["builder:claude"], config=UNCONFIGURED + ) + self.assertEqual(result["status"], "resolved") + self.assertEqual(result["current_writer"], "claude") + + def test_a_recorded_handoff_resolves_to_its_current_writer(self): + result = self._lineage( + branch="devin/topic", + labels=["builder:codex"], + config=CONFIG, + comments=[{"user": {"login": AUTHORITY}, + "body": _handoff_marker("devin/topic")}], + author="devin-ai-integration[bot]", + ) + self.assertEqual(result["status"], "resolved") + self.assertEqual(result["current_writer"], "codex") + self.assertEqual(sorted(result["contributors"]), ["codex", "devin"]) + + def test_a_malformed_published_history_is_one_bounded_conflict(self): + result = self._lineage( + branch="claude/topic", + labels=["builder:claude"], + config=CONFIG, + comments=[{"user": {"login": AUTHORITY}, "body": 12345}], + ) + self.assertEqual(result["status"], "conflict") + self.assertTrue(result["owner_action"]) + self.assertEqual(result["current_writer"], "") + + def test_a_genuinely_empty_history_stays_ordinary(self): + result = self._lineage( + branch="claude/topic", labels=["builder:claude"], config=CONFIG, + comments=[], + ) + self.assertEqual(result["status"], "resolved") + + def test_the_status_projection_carries_the_same_answer(self): + """Through `_summarize_pr`, the consumer status actually renders.""" + + env = { + "CODE_MOWER_AUTHOR_EXCLUSION_JSON": json.dumps(CONFIG), + "CODE_MOWER_DECISION_AUTHORITIES": AUTHORITY, + "CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE": "", + } + pr = { + "number": 7, + "headRefName": "codex/topic", + "headRefOid": HEAD, + "author": {"login": "a-human"}, + "labels": [{"name": "builder:claude"}], + "comments": [], + "isDraft": True, + "updatedAt": "2026-01-01T00:00:00Z", + } + from datetime import UTC, datetime + + with mock.patch.dict(os.environ, env, clear=False): + summary = lane_status._summarize_pr( + "owner/repo", pr, datetime.now(UTC), 60 + ) + self.assertEqual(summary["builder_lineage"]["status"], "conflict") + self.assertEqual(summary["builder_lineage"]["current_writer"], "") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index ef5316fa..2e246d05 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -101,6 +101,25 @@ def _reported_manifest_identity(manifest_bytes: bytes) -> dict: } +def _gate_takeover_episode(): + """One verified Devin -> Codex handoff, for marker fixtures.""" + + from code_mower import builder_lineage + + return builder_lineage.ContributionEpisode( + sequence=1, + kind=builder_lineage.HANDOFF_KIND, + repo="owner/repo", + pr_number=7, + branch="devin/topic", + source_lane="devin", + destination_lane="codex", + expected_head="a" * 40, + resulting_head="b" * 40, + writer_state="terminated", + ) + + class ReleaseHygieneTests(unittest.TestCase): def test_version_is_current_supervised_pilot_release(self) -> None: self.assertEqual(__version__, "1.4.0") @@ -1676,6 +1695,7 @@ def _run_gate_template_decision( lanes: list[dict[str, str]], labels: set[str], comments: list[dict[str, object]] | None = None, + comment_pages: object = None, events: list[dict[str, object]] | None = None, event_pages: list[list[dict[str, object]]] | None = None, head_sha: str = "a" * 40, @@ -1686,6 +1706,8 @@ def _run_gate_template_decision( commit_pull_requests: dict[str, list[dict[str, object]]] | None = None, author_exclusion: dict[str, object] | None = None, owner_login: str = "owner", + pr_author: str = "", + branch: str = "", env: dict[str, str] | None = None, ) -> dict[str, str]: template = ( @@ -1701,13 +1723,24 @@ def _run_gate_template_decision( json.dump([{"name": label} for label in sorted(labels)], handle) labels_path = handle.name with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: - json.dump([comments or []], handle) + # `comment_pages` writes the raw paginated payload, so a test can + # hand the gate the shapes GitHub could actually return. + json.dump( + comment_pages if comment_pages is not None else [comments or []], + handle, + ) comments_path = handle.name with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: json.dump(event_pages if event_pages is not None else [events or []], handle) events_path = handle.name with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: - json.dump({"number": pr_number, "head": {"sha": pr_head_sha or head_sha}}, handle) + pr_payload: dict[str, object] = { + "number": pr_number, + "head": {"sha": pr_head_sha or head_sha, "ref": branch}, + } + if pr_author: + pr_payload["user"] = {"login": pr_author} + json.dump(pr_payload, handle) pr_path = handle.name with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: json.dump(audit_runs or [], handle) @@ -1814,6 +1847,127 @@ def fake_github_request( def _bound_actions_body(self, body: str, *, comment_id: int = 1234) -> str: return provider_runners.bind_actions_run_comment_id(body, comment_id) + _GATE_LANES = [ + { + "id": "codex", + "display_name": "Codex", + "done": "codex-audit-done", + "blocked": "codex-audit-blocked", + "author_lane": "codex", + "builder_label": "builder:codex", + "bot_authors": "codex-audit-bot,codex-audit-bot[bot]", + } + ] + + def _gate_over_comment_pages(self, pages): + return self._run_gate_template_decision( + lanes=self._GATE_LANES, + labels={"builder:codex"}, + comment_pages=pages, + owner_login="owner", + ) + + def test_gate_refuses_a_comment_history_it_cannot_read(self) -> None: + """The rendered gate, over raw pages -- not a lower helper. + + The generic flattener drops members it cannot use and `str(... or "")` + turns a present non-string body into plausible text, so an unreadable + history reached the gate looking ordinary and lineage stayed readable. + """ + + marker_author = {"login": "owner"} + unreadable_pages = ( + {"comments": []}, + [{"body": "hi", "user": marker_author}, "not a comment"], + # A bare object where a page belongs. `gh api --paginate --slurp` + # returns a list of pages, so these are not pages at all -- and + # read as one-comment pages they looked like an absent history, + # leaving the gate waiting for an audit instead of refusing. + [{}], + [{"comments": []}], + [[], {"items": []}], + [None], + [False], + ["page"], + [[[{"body": "hi", "user": marker_author}]]], + [[{"body": 12345, "user": marker_author}]], + [[{"body": None, "user": marker_author}]], + [[{"body": "hi", "user": "owner"}]], + [[{"body": "hi", "user": {"login": 7}}]], + [[{"body": "hi", "user": {"login": None}}]], + ) + for pages in unreadable_pages: + with self.subTest(pages=pages): + result = self._gate_over_comment_pages(pages) + self.assertEqual(result["gate_state"], "failure", result) + self.assertIn( + "builder contribution evidence is unreadable", + result["gate_description"], + ) + + def test_gate_accepts_githubs_own_comment_schema(self) -> None: + """Positive control, valid fixtures only: none of this is unreadable.""" + + pages = [ + [ + {"user": None, "body": "a deleted account said this"}, + {"user": {"login": "someone"}}, + {"user": {"login": "owner"}, "body": "ordinary comment"}, + ], + [], + ] + result = self._gate_over_comment_pages(pages) + self.assertNotIn( + "unreadable", result["gate_description"], result + ) + + def test_gate_accepts_a_genuinely_empty_comment_history(self) -> None: + """Valid slurped shapes stay ordinary, including empty pages.""" + + for pages in ( + [], + [[]], + [[], []], + [ + [{"user": {"login": "someone"}, "body": "first page"}], + [{"user": {"login": "someone"}, "body": "second page"}], + ], + [[{"user": {"login": "someone"}, "body": "only page"}], []], + ): + with self.subTest(pages=pages): + result = self._gate_over_comment_pages(pages) + self.assertNotIn("unreadable", result["gate_description"], result) + + def test_gate_refuses_a_trusted_marker_declaring_no_episodes(self) -> None: + """An announced empty chain is not an ordinary absence of lineage.""" + + from code_mower import builder_lineage + + empty_marker = builder_lineage.lineage_comment_marker(()) + valid_marker = builder_lineage.lineage_comment_marker( + (_gate_takeover_episode(),) + ) + alone = [[{"user": {"login": "owner"}, "body": "Lineage\n\n" + empty_marker}]] + mixed = [ + [ + {"user": {"login": "owner"}, "body": "Lineage\n\n" + valid_marker}, + {"user": {"login": "owner"}, "body": "Lineage\n\n" + empty_marker}, + ] + ] + for label, pages in (("alone", alone), ("mixed", mixed)): + with self.subTest(case=label): + result = self._gate_over_comment_pages(pages) + self.assertEqual(result["gate_state"], "failure", result) + self.assertIn( + "builder contribution evidence is unreadable", + result["gate_description"], + ) + + def test_gate_reads_an_ordinary_unrelated_comment_history(self) -> None: + pages = [[{"user": {"login": "someone"}, "body": "looks good"}]] + result = self._gate_over_comment_pages(pages) + self.assertNotIn("unreadable", result["gate_description"], result) + def test_gate_decision_rejects_builder_exclusion_with_no_independent_lane( self, ) -> None: @@ -1883,6 +2037,120 @@ def test_gate_decision_allows_author_exclusion_with_peer_pass(self) -> None: self.assertEqual(result["gate_state"], "success") self.assertEqual(result["gate_description"], "Code Mower merge gate passed") + # The lineage marker is a transport, never an authorization. The rendered + # gate must read it under the same configured decision-authority contract + # as the publisher and every other lineage consumer, so these drive the + # actual gate decision rather than the resolver helper underneath it. + _LINEAGE_HEAD = "a" * 40 + _LINEAGE_OPENED = "d" * 40 + _LINEAGE_BRANCH = "devin/7-release-dogfood" + _LINEAGE_AUTHOR = "devin-ai-integration[bot]" + + def _lineage_gate_lanes(self) -> list[dict[str, str]]: + return [ + { + "id": "codex", + "display_name": "Codex", + "done": "codex-audit-done", + "blocked": "codex-audit-blocked", + "author_lane": "codex", + "builder_label": "builder:codex", + "bot_authors": "codex-audit-bot,codex-audit-bot[bot]", + }, + { + "id": "claude", + "display_name": "Claude", + "done": "claude-audit-done", + "blocked": "claude-audit-blocked", + "author_lane": "claude", + "builder_label": "builder:claude", + "bot_authors": "claude-audit-bot,claude-audit-bot[bot]", + }, + { + "id": "devin", + "display_name": "Devin", + "done": "devin-audit-done", + "blocked": "devin-audit-blocked", + "author_lane": "devin", + "builder_label": "builder:devin", + "bot_authors": "devin-audit-bot,devin-ai-integration[bot]", + }, + ] + + def _run_lineage_gate_decision(self, *, marker_author: str) -> dict[str, str]: + """The rendered gate on a verified Devin -> Codex takeover. + + Devin opened the pull request, Codex wrote the head, and independent + Claude passed at that exact head. Only ``marker_author`` varies. + """ + + from code_mower import builder_lineage + + takeover = builder_lineage.ContributionEpisode( + sequence=1, + kind=builder_lineage.HANDOFF_KIND, + repo="owner/repo", + pr_number=7, + branch=self._LINEAGE_BRANCH, + source_lane="devin", + destination_lane="codex", + expected_head=self._LINEAGE_OPENED, + resulting_head=self._LINEAGE_HEAD, + writer_state="terminated", + ) + return self._run_gate_template_decision( + lanes=self._lineage_gate_lanes(), + labels={"builder:codex", "claude-audit-done"}, + head_sha=self._LINEAGE_HEAD, + pr_author=self._LINEAGE_AUTHOR, + branch=self._LINEAGE_BRANCH, + author_exclusion={ + "enabled": True, + "labels": { + "builder:codex": "codex", + "builder:claude": "claude", + "builder:devin": "devin", + }, + "authors": {self._LINEAGE_AUTHOR: "devin"}, + }, + comments=[ + { + "body": "Builder contribution lineage for this head.\n\n" + + builder_lineage.lineage_comment_marker((takeover,)), + "user": {"login": marker_author}, + }, + { + "body": "Head SHA: `" + self._LINEAGE_HEAD + "`\n" + "", + "user": {"login": "claude-audit-bot"}, + }, + ], + ) + + def test_gate_trusts_lineage_published_by_a_decision_authority(self) -> None: + # A configured decision authority attests the takeover, so Codex as + # well as the Devin opener are contributors and independent Claude's + # exact-head pass is the qualifying audit. + result = self._run_lineage_gate_decision(marker_author="owner") + + self.assertEqual(result["gate_state"], "success") + self.assertEqual(result["gate_description"], "Code Mower merge gate passed") + + def test_gate_refuses_lineage_published_by_a_non_authority_audit_bot(self) -> None: + # The identical marker from a reviewer bot that may post verdicts but + # holds no decision authority establishes no contributor history. The + # gate is left with the unexplained author/label disagreement this + # issue exists to surface, so it fails closed instead of passing. + result = self._run_lineage_gate_decision(marker_author="codex-audit-bot") + + self.assertEqual(result["gate_state"], "failure") + self.assertNotEqual( + result["gate_description"], "Code Mower merge gate passed" + ) + self.assertIn( + "conflicting Code Mower builder identity", result["gate_description"] + ) + def test_gate_reads_required_policy_from_current_trusted_checkout(self) -> None: # Execute the shipped workflow decision with unchanged old review/head # while only trusted configuration changes, without regenerating it. diff --git a/tests/test_trailer_lineage_history.py b/tests/test_trailer_lineage_history.py new file mode 100644 index 00000000..f04ee88d --- /dev/null +++ b/tests/test_trailer_lineage_history.py @@ -0,0 +1,203 @@ +"""The actual trailer labeler route, over the lowest GitHub request. + +``trailer_comment_labeler.main`` fetches the verdict history itself and hands +it to ``lineage_context`` before deciding a label. That fetch used to apply +``or []`` and filter non-dicts away, so a falsey or malformed *successful* +page became an empty history -- and an empty history is an ordinary answer, so +the run went on to mutate a label anyway. These drive the real route and mock +only the lowest request, because validating a value handed straight to a +helper would walk past the normalisation that caused the problem. + +Written against ``unittest`` on purpose: CI runs these through +``python -m unittest discover -s tests``, where pytest is not installed, so a +pytest-only file would be silently undiscovered rather than loudly missing. +""" + +from __future__ import annotations + +import ast +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from code_mower import audit_labeler_lib as labeler_lib # noqa: E402 +from code_mower import trailer_comment_labeler # noqa: E402 + +from test_trailer_comment_labeler import HEAD_SHA, _event # noqa: E402 + +MALFORMED_TRAILER_HISTORIES = ( + None, + False, + {}, + [{"user": {"login": "codex-audit-bot"}, "body": "hi"}, "not a comment"], + [{"user": {"login": "codex-audit-bot"}, "body": 12345}], + [{"user": {"login": "codex-audit-bot"}, "body": None}], + [{"user": "codex-audit-bot", "body": "hi"}], + [{"user": {"login": {"name": "codex-audit-bot"}}, "body": "hi"}], + [{"user": {"login": None}, "body": "hi"}], +) + + +def _verdict_body() -> str: + return ( + "Codex Audit - PASS\n" + f"Head SHA: `{HEAD_SHA}`\n" + "" + ) + + +class TrailerHistoryMustBeReadable(unittest.TestCase): + """The real `main` -> lower request -> `lineage_context` -> label route.""" + + def _trailer_main(self, api): + """Run the real `main`; `fetch_issue_comments` is deliberately untouched.""" + + applied: list = [] + with tempfile.TemporaryDirectory() as tmp: + event_path = Path(tmp) / "event.json" + event_path.write_text( + json.dumps(_event("codex-audit-bot", _verdict_body())), + encoding="utf-8", + ) + env = { + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": "owner/repo", + "GITHUB_TOKEN": "token", + } + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch.object( + trailer_comment_labeler, + "fetch_pull_request", + lambda *_a, **_k: {"head": {"sha": HEAD_SHA}}, + ), \ + mock.patch.object( + labeler_lib, "github_request_with_fallback", api + ), \ + mock.patch.object( + trailer_comment_labeler, + "apply_label_decision", + lambda repo, decision, **_k: applied.append((repo, decision)), + ): + os.environ.pop("CODEX_BOT_AUTHORS", None) + code = trailer_comment_labeler.main(["--lane", "codex"]) + return code, applied + + def test_an_unreadable_history_moves_no_label(self): + for history in MALFORMED_TRAILER_HISTORIES: + with self.subTest(history=history): + _, applied = self._trailer_main( + lambda *_a, _history=history, **_k: _history + ) + self.assertEqual( + applied, [], "an unreadable history may move no label" + ) + + def test_githubs_own_comment_schema_still_decides(self): + """Positive control, valid fixtures only: none of this is an error.""" + + valid = [ + {"user": None, "body": "a deleted account said this"}, + {"user": {"login": "someone"}}, + {"user": {"login": "codex-audit-bot"}, "body": _verdict_body()}, + ] + code, applied = self._trailer_main( + lambda method, path, **_k: valid if "page=1" in path else [] + ) + self.assertEqual(code, 0) + self.assertTrue(applied, "a valid history still reaches a label decision") + + def test_a_genuinely_empty_history_still_decides(self): + code, applied = self._trailer_main(lambda *_a, **_k: []) + self.assertEqual(code, 0) + self.assertTrue( + applied, "no comment history is ordinary; the event still decides" + ) + + def test_an_announced_empty_lineage_marker_is_not_absence(self): + """A trusted marker declaring no episodes contradicts itself.""" + + from code_mower import builder_lineage + + marker = builder_lineage.lineage_comment_marker(()) + history = [ + {"user": {"login": "codemower-ai"}, "body": "Lineage\n\n" + marker}, + {"user": {"login": "codex-audit-bot"}, "body": _verdict_body()}, + ] + with mock.patch.dict( + os.environ, {"CODE_MOWER_DECISION_AUTHORITIES": "codemower-ai"}, clear=False + ): + _, applied = self._trailer_main( + lambda method, path, **_k: history if "page=1" in path else [] + ) + self.assertEqual(applied, [], "announced lineage may not read as absence") + + +class TheseCasesRunWithoutPytest(unittest.TestCase): + """CI runs `python -m unittest discover -s tests`, with no pytest installed. + + A pytest-only module is not an error there -- it is silently undiscovered, + which is worse than a failure, because the coverage simply stops existing. + So the discovery CI performs is exercised here with unittest's own loader. + """ + + MODULE = "test_trailer_lineage_history" + + def test_the_module_imports_no_pytest(self): + """Checked structurally: a mention in prose is not a dependency.""" + + tree = ast.parse(Path(__file__).read_text(encoding="utf-8")) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".")[0]) + self.assertNotIn("pytest", imported) + + def test_unittest_discovery_finds_every_case(self): + suite = unittest.TestLoader().discover( + str(Path(__file__).resolve().parent), + pattern=Path(__file__).name, + top_level_dir=str(Path(__file__).resolve().parent), + ) + found = {case.id().rsplit(".", 1)[-1] for case in _flatten(suite)} + for name in ( + "test_an_unreadable_history_moves_no_label", + "test_githubs_own_comment_schema_still_decides", + "test_a_genuinely_empty_history_still_decides", + "test_an_announced_empty_lineage_marker_is_not_absence", + ): + with self.subTest(case=name): + self.assertIn(name, found) + + def test_unittest_executes_the_cases_it_discovers(self): + # Loaded by name rather than by discovery, so this class -- and this + # very test -- stays out of the suite being run. + suite = unittest.TestLoader().loadTestsFromName( + f"{self.MODULE}.{TrailerHistoryMustBeReadable.__name__}" + ) + self.assertGreater(suite.countTestCases(), 0) + result = unittest.TextTestRunner( + stream=io.StringIO(), verbosity=0 + ).run(suite) + self.assertTrue(result.wasSuccessful(), result.errors + result.failures) + + +def _flatten(suite): + for item in suite: + if isinstance(item, unittest.TestSuite): + yield from _flatten(item) + else: + yield item + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tools/audit_labeler_lib.py b/tools/audit_labeler_lib.py index 797a21f9..30a0427e 100644 --- a/tools/audit_labeler_lib.py +++ b/tools/audit_labeler_lib.py @@ -26,12 +26,45 @@ try: from . import decisions as code_mower_decisions from . import context_review as code_mower_context_review + from .builder_lineage import ( + LINEAGE_MARKER, + Lineage, + LineageError, + episodes_from_comment_body, + branch_lane_from_identity, + lanes_from_identity, + require_comment_list, + resolve_identity_only, + resolve_lineage, + ) except ImportError: # pragma: no cover - copied tools fallback import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore + from builder_lineage import ( # type: ignore + LINEAGE_MARKER, + Lineage, + LineageError, + episodes_from_comment_body, + branch_lane_from_identity, + lanes_from_identity, + require_comment_list, + resolve_identity_only, + resolve_lineage, + ) else: # pragma: no cover - direct helper execution import decisions as code_mower_decisions # type: ignore import context_review as code_mower_context_review # type: ignore + from builder_lineage import ( # type: ignore + LINEAGE_MARKER, + Lineage, + LineageError, + episodes_from_comment_body, + branch_lane_from_identity, + lanes_from_identity, + require_comment_list, + resolve_identity_only, + resolve_lineage, + ) MIN_ABBREVIATED_SHA_LENGTH = 7 AUTHOR_EXCLUSION_ENV = "CODE_MOWER_AUTHOR_EXCLUSION_JSON" @@ -196,32 +229,201 @@ def load_author_exclusion_config(raw: str | None = None) -> Mapping[str, Any]: return parsed if isinstance(parsed, Mapping) else {"enabled": False} +def resolve_builder_lineage( + *, + labels: Sequence[str], + author: str, + config: Mapping[str, Any], + repo: str = "", + pr_number: Any = 0, + branch: str = "", + head_sha: str = "", + episodes: Sequence[Any] = (), +) -> Lineage: + """Resolve the shared builder lineage from labeler-side evidence. + + Labelers see labels and an author for certain, and may or may not have the + exact head plus published contribution episodes to hand. Without exact-head + evidence there is no takeover to resolve, so the result is the ordinary + single-builder decision; with it, the same resolver the gate, the runner + and the reviewer wrappers use answers the question. + """ + + opener_lane, label_lanes = lanes_from_identity( + identity=config, labels=labels, author=author + ) + # The configured branch identity is a signal the deployment asked to be + # counted; rendering it and then resolving without it is how a `codex/` + # branch labelled `builder:claude` resolved to a sole Claude writer and + # admitted Codex to review its own diff. + branch_lane = branch_lane_from_identity(identity=config, branch=branch) + if not (head_sha and repo and pr_number): + return resolve_identity_only( + opener_lane=opener_lane, + label_lanes=label_lanes, + branch_lane=branch_lane, + ) + return resolve_lineage( + repo=repo, + pr_number=pr_number, + branch=branch, + head_sha=head_sha, + episodes=episodes, + opener_lane=opener_lane, + label_lanes=label_lanes, + branch_lane=branch_lane, + ) + + +@dataclass(frozen=True) +class LineageContext: + """The trusted exact-head evidence a labeler carries into resolution. + + Every field has to come from something the labeler verified for itself: the + repository it is running in, the head it fetched from the pull request, and + episodes published by an author it already trusts. An empty context is not + a failure -- it is the honest statement that this call has no exact-head + evidence, and resolution falls back to the ordinary single-builder answer. + """ + + repo: str = "" + pr_number: Any = 0 + branch: str = "" + head_sha: str = "" + episodes: tuple[Any, ...] = () + + +#: A labeler that has no exact-head evidence at all. +NO_LINEAGE = LineageContext() + + +def published_lineage_episodes( + comments: Sequence[Mapping[str, Any]] | None, + *, + trusted_author: Callable[[str], bool], +) -> tuple[Any, ...]: + """Contribution episodes published by comment authors the caller trusts. + + The hidden marker is a transport for bounded metadata, never an + authorization: trust is decided entirely by ``trusted_author``, so an + arbitrary commenter cannot assert a takeover into existence. Unreadable + published evidence raises :class:`LineageError` so the caller fails closed + rather than labelling from a partially parsed history. + """ + + collected: list[Any] = [] + for comment in comments or (): + if not isinstance(comment, Mapping): + continue + login = str(((comment.get("user") or {}).get("login")) or "") + body = str(comment.get("body") or "") + if not login or LINEAGE_MARKER not in body or not trusted_author(login): + continue + collected.extend(episodes_from_comment_body(body)) + return tuple(collected) + + +def lineage_decision_authorities() -> tuple[str, ...]: + """The repository's configured decision authorities, for marker trust.""" + + return code_mower_decisions.decision_authorities_from_env() + + +def lineage_marker_author_trust( + *, + authorities: Sequence[str] = (), +) -> Callable[[str], bool]: + """Who this labeler may read published lineage markers from. + + Trust is exactly the repository's configured decision authorities -- the + accounts it already treats as authoritative about its own state. Nothing + else is accepted, and in particular an audit bot able to post a verdict is + not thereby able to assert a takeover. An unconfigured checkout trusts + nobody, reads no episodes, and behaves exactly as it does today. + """ + + allowed = { + str(item).strip().lower().lstrip("@") + for item in authorities + if str(item).strip() + } + + def trusted(login: str) -> bool: + return bool(allowed) and str(login).strip().lower().lstrip("@") in allowed + + return trusted + + +def lineage_context( + *, + repo: str, + pr_number: Any, + branch: str = "", + head_sha: str | None = "", + comments: Sequence[Mapping[str, Any]] | None = (), + trusted_author: Callable[[str], bool] | None = None, +) -> LineageContext: + """Assemble exact-head lineage evidence for a labeler entry path. + + Returns :data:`NO_LINEAGE` when the head could not be verified, because + lineage resolved against an unverified head would decide the wrong diff. + Unreadable published evidence is propagated as a + :class:`LineageError` for the caller's fail-closed handling. + """ + + head = str(head_sha or "") + if not head or not repo or not pr_number: + return NO_LINEAGE + episodes: tuple[Any, ...] = () + if trusted_author is not None: + episodes = published_lineage_episodes(comments, trusted_author=trusted_author) + return LineageContext( + repo=str(repo), + pr_number=pr_number, + branch=str(branch or ""), + head_sha=head, + episodes=episodes, + ) + + def builder_identity_matches( *, labels: Sequence[str], author: str, text: str, config: Mapping[str, Any], + lineage: LineageContext | None = None, ) -> tuple[str, ...]: - if not bool(config.get("enabled")): - return () - label_map = _string_mapping(config.get("labels")) - author_map = { - key.lower(): value - for key, value in _string_mapping(config.get("authors")).items() - } - matches: list[str] = [] + """Ordered verified builder lanes for this pull request. - for label in labels: - lane = label_map.get(str(label)) - if lane: - matches.append(lane) + ``text`` is retained for call compatibility and is deliberately unused: + freeform prose is not contribution evidence. + """ - lane = author_map.get(author.lower()) - if lane: - matches.append(lane) - - return tuple(dict.fromkeys(matches)) + if not bool(config.get("enabled")): + return () + evidence = lineage or NO_LINEAGE + resolved = resolve_builder_lineage( + labels=labels, + author=author, + config=config, + repo=evidence.repo, + pr_number=evidence.pr_number, + branch=evidence.branch, + head_sha=evidence.head_sha, + episodes=evidence.episodes, + ) + if resolved.status != "resolved": + # Preserve the historical "more than one identity" shape so a caller + # inspecting matches can still tell a conflict from a clean match. + opener_lane, label_lanes = lanes_from_identity( + identity=config, labels=labels, author=author + ) + candidates = tuple( + dict.fromkeys(list(label_lanes) + ([opener_lane] if opener_lane else [])) + ) + return candidates if len(candidates) > 1 else resolved.contributors + return resolved.contributors def author_exclusion_reason( @@ -231,20 +433,40 @@ def author_exclusion_reason( author: str, text: str, config: Mapping[str, Any] | None = None, + lineage: LineageContext | None = None, ) -> str | None: + """Why ``lane_name`` may not label its own work, or ``None``. + + Exclusion follows verified contribution, not authorship: every contributing + lane is excluded, and a lane that merely opened the PR before handing it + over is excluded too because its commits are still in the diff. Conversely, + with exact-head evidence in ``lineage``, a lane that never touched this + head is *not* excluded even when a reconciled label names a different lane + than the opener -- which is the whole point of carrying the evidence here. + """ + exclusion_config = config or load_author_exclusion_config() - matches = builder_identity_matches( - labels=labels, - author=author, - text=text, - config=exclusion_config, - ) - if not matches: + if not bool(exclusion_config.get("enabled")): return None - if len(set(matches)) > 1: + evidence = lineage or NO_LINEAGE + try: + resolved = resolve_builder_lineage( + labels=labels, + author=author, + config=exclusion_config, + repo=evidence.repo, + pr_number=evidence.pr_number, + branch=evidence.branch, + head_sha=evidence.head_sha, + episodes=evidence.episodes, + ) + except LineageError: + return "builder contribution evidence is unreadable; skipping author-excluded label update" + if resolved.status == "conflict": return "conflicting builder identity; skipping author-excluded label update" - builder_lane = matches[0] - if builder_lane == lane_name: + if resolved.status == "waiting": + return "builder contribution lineage is behind the current head; skipping author-excluded label update" + if resolved.contributed(lane_name): return f"{lane_name} lane excluded for builder-authored PR" return None @@ -400,6 +622,42 @@ def flatten_paginated_items(payload: Any) -> list[dict[str, Any]]: return items +def flatten_paginated_comments(payload: Any) -> list[dict[str, Any]]: + """Flatten a paginated *comment* response under the shared record contract. + + :func:`flatten_paginated_items` is for timeline entries and stays as it is: + it drops members it cannot use, which is right for a mixed event stream and + wrong for a comment history. A dropped comment is a dropped marker, and the + marker proving a takeover is in the newest part of the history. So comment + pages are validated here -- before anything flattens, filters or + stringifies them -- and an unreadable page raises rather than shrinking. + + Genuinely empty pages, ``user: null`` and an omitted optional ``body`` are + GitHub's own schema and stay ordinary. + """ + + if not isinstance(payload, list): + raise LineageError("the comment history did not come back as a paginated array") + comments: list[dict[str, Any]] = [] + for index, page in enumerate(payload, start=1): + # Every page is an array. The gate fetches `gh api --paginate --slurp`, + # whose shape is a list of pages, so anything else in that position is + # not a page. Accepting a bare object as a one-comment page -- which is + # what the generic flattener does -- reinterprets `{}` or + # `{"comments": []}` as a comment with no body and no author, and a + # response nobody could read then looks like an absent history: the + # gate waits for an audit instead of refusing. Record validation cannot + # recover that, because by then the wrapper has already made the + # payload look well formed. + if not isinstance(page, list): + raise LineageError(f"comment page {index} is not an array of comments") + comments.extend( + dict(comment) + for comment in require_comment_list(page, what=f"comment page {index}") + ) + return comments + + def audit_comment_head_sha(body: str) -> str: match = HEAD_SHA_LINE_RE.search(body) return match.group(1) if match else "" @@ -936,13 +1194,23 @@ def fetch_issue_comments( "GET", f"/repos/{repo}/issues/{issue_number}/comments?per_page=100&page={page}", tokens=tokens, - ) or [] - if not isinstance(chunk, list): - raise RuntimeError("GitHub API issue comments returned a non-list response") - if not chunk: + ) + # Shape and the fields lineage reads are settled before anything else + # looks at the page. `or []` made `None`, `False` and `{}` -- every + # falsey successful response -- end the read, and filtering by + # `isinstance` dropped malformed records silently. Both report "there + # is nothing here" for "this could not be read", and this page feeds + # the trailer labeler's own label decision. + try: + page_comments = require_comment_list( + chunk, what=f"issue comments page {page} for {repo}#{issue_number}" + ) + except LineageError as exc: + raise RuntimeError(str(exc)) from None + if not page_comments: return comments - comments.extend(comment for comment in chunk if isinstance(comment, dict)) - if len(chunk) < 100: + comments.extend(dict(comment) for comment in page_comments) + if len(page_comments) < 100: return comments page += 1 raise IssueCommentPaginationLimitExceeded( diff --git a/tools/builder_lineage.py b/tools/builder_lineage.py new file mode 100644 index 00000000..2b28fe12 --- /dev/null +++ b/tools/builder_lineage.py @@ -0,0 +1,1135 @@ +"""Exact-head builder contribution lineage after a verified handoff. + +A pull request can be built by more than one Code Mower builder lane. The +opener, the branch prefix and the single active ``builder:*`` label each +describe at most one of those lanes, so any of them alone will misdescribe a +PR that changed hands. This module keeps the ordered contribution history +instead, and derives the one current writer from it. + +Trust rules this module exists to enforce: + +* A contribution episode is evidence produced by the verified handoff and + delivery path (see :mod:`code_mower.lane_handoff`), which observed the source + writer going quiescent and observed both heads. A caller-supplied boolean, a + PR body marker, a commit trailer, the PR opener or the most recent label are + none of them able to attest that a takeover happened. +* Episodes are bound to repository, pull request, branch, source lane, + destination lane, expected head and resulting head. An episode that does not + bind to the pull request under decision is not evidence about it. +* Resolution is exact-head. Lineage that stops short of the current head is + *waiting*, never a guess about who wrote the current diff. +* Conflicting, duplicated, unchained or unbound evidence fails closed with one + concise owner action rather than picking a winner. + +Everything here is metadata-only: lane names, a repository slug, a PR number, a +branch name and commit shas. No source, diffs, prompts, transcripts, paths, +provider references or credentials pass through this module, so a resolved +lineage is safe for the existing public/cloud metadata contract. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping, Sequence + + +SCHEMA = "code_mower.builderLineage.v1" +EPISODE_SCHEMA = "code_mower.contributionEpisode.v1" +RECORD_SCHEMA = "code_mower.builderLineageRecord.v1" + +#: Hidden marker used to publish bounded lineage metadata on a pull request. +#: Only comments from an already trusted author are parsed; the marker is a +#: transport, never an authorization. +LINEAGE_MARKER = "CODE_MOWER_BUILDER_LINEAGE" +LINEAGE_MARKER_RE = re.compile( + r"", + re.DOTALL, +) + +#: Marker *presence*, decided without looking at the payload at all. +#: +#: :data:`LINEAGE_MARKER_RE` only matches a complete, object-shaped, properly +#: terminated marker. Looking for published evidence with it alone means an +#: unterminated or non-object marker is not read as broken -- it is not seen, +#: and a trusted comment that announces lineage reports none. Absence and +#: unreadability are opposite answers: one admits an independent reviewer on +#: the ordinary single-builder story, the other must stop. Presence is found +#: first, and the payload is then required to parse. +LINEAGE_MARKER_PRESENT_RE = re.compile( + r"" + + +def _reject_duplicate_keys(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + """``object_pairs_hook`` that refuses an object naming a key twice. + + ``json.loads`` keeps the last value for a repeated key, so one marker can + carry two answers to the same question -- two ``episodes`` lists, two + ``schema`` values, two ``resulting_head`` shas inside one episode -- and + every reader silently agrees on whichever came last. Which one describes + the diff is exactly what must not be decided by parser order. This applies + at every depth, so a conflicting nested binding is refused too. + """ + + seen: dict[str, Any] = {} + for key, value in pairs: + if key in seen: + raise ValueError(f"duplicate key in published builder lineage: {key}") + seen[key] = value + return seen + + +def _loads_without_duplicate_keys(payload: str) -> Any: + return json.loads(payload, object_pairs_hook=_reject_duplicate_keys) + + +def episodes_from_comment_body(body: str) -> tuple[ContributionEpisode, ...]: + """Parse lineage markers out of one already trusted comment body. + + The caller decides trust. This function never treats the presence of a + marker as evidence that its author was allowed to publish one. + """ + + text = _text(body) + present = LINEAGE_MARKER_PRESENT_RE.findall(text) + if not present: + # An ordinary comment. Not evidence, and not a failure either. + return () + if len(present) > 1: + # Two markers on one comment cannot both be "the" published lineage, + # and which one describes the head is exactly what may not be guessed. + raise LineageError("published builder lineage is ambiguous") + matches = LINEAGE_MARKER_RE.findall(text[:MAX_MARKER_BODY_CHARS]) + if len(matches) != 1: + # The marker is there, but no single complete object payload parses out + # of it: unterminated, not an object, or cut off past the bound. + raise LineageError("published builder lineage is unreadable") + try: + payload = _loads_without_duplicate_keys(matches[0]) + except (ValueError, RecursionError): + raise LineageError("published builder lineage is unreadable") from None + if not isinstance(payload, Mapping) or payload.get("schema") != SCHEMA: + raise LineageError("published builder lineage schema is unsupported") + items = payload.get("episodes") + if not isinstance(items, list) or len(items) > MAX_EPISODES: + raise LineageError("published builder lineage is unreadable") + if not items: + # A marker announces lineage. The publisher refuses to publish zero + # episodes, so a trusted marker carrying an empty chain is not a + # history that happens to be empty -- it is a claim that contradicts + # itself, and reading it as ordinary absence is how announced evidence + # disappears into the single-builder answer. + raise LineageError("published builder lineage declares no episodes") + return tuple(episode_from_mapping(item) for item in items) diff --git a/tools/decisions.py b/tools/decisions.py index 5b4a885d..c0484c76 100644 --- a/tools/decisions.py +++ b/tools/decisions.py @@ -242,7 +242,10 @@ def decision_authorities_from_env(raw: str | None = None) -> tuple[str, ...]: text = ( raw if raw is not None - else os.environ.get("CODE_MOWER_DECISION_AUTHORITIES", "") + else ( + os.environ.get("CODE_MOWER_DECISION_AUTHORITIES_OVERRIDE", "").strip() + or os.environ.get("CODE_MOWER_DECISION_AUTHORITIES", "") + ) ) return tuple(item.strip() for item in text.split(",") if item.strip()) diff --git a/tools/lanes/run_mac_lane.sh b/tools/lanes/run_mac_lane.sh index f99506e8..ce8354cc 100755 --- a/tools/lanes/run_mac_lane.sh +++ b/tools/lanes/run_mac_lane.sh @@ -949,6 +949,11 @@ snapshot_lookup() { # from "no PR yet" or "no head yet", so a transient failure on one side of the # comparison would fabricate a pr_opened or head_advanced transition for a # target that never moved. +# +# The head branch comes out of the same authenticated pull request read as the +# head sha, so the two describe one observation. Contribution lineage binds an +# episode to repository, PR, branch and head together; re-resolving the branch +# from anywhere else later would bind it to something this snapshot never saw. capture_target_state() { local out="$1" local runner_comment_id="${2:-}" @@ -971,7 +976,7 @@ capture_target_state() { fi if [ -n "$pr_number" ]; then if ! pr_json="$(snapshot_lookup gh pr view "$pr_number" -R "$REPO" \ - --json headRefOid,state,labels 2>/dev/null)"; then + --json headRefName,headRefOid,state,labels 2>/dev/null)"; then pr_json='{}' complete=false fi @@ -993,6 +998,7 @@ capture_target_state() { number: $number, pr_number: $pr, head_sha: ((.headRefOid // "") | ascii_downcase), + branch: (.headRefName // ""), pr_state: (.state // ""), labels: $labels, runner_comment_id: $comment, @@ -1565,11 +1571,55 @@ if [ "${#lane_delivery[@]}" -gt 0 ] && [ "$mode" != "audit" ]; then --output "${log%.log}.delivery.json" --force ) + # A delivered handoff round records its ordered contribution episode against + # the accepted private intent. The episode is written from the acceptance + # record and a fresh head observation, never from anything declared here, so + # a runner that merely names a handoff records nothing. + # The state directory travels even without a handoff: an ordinary fix round + # after a takeover has no new handoff to record but still advances the head, + # and lineage that stops short of it refuses every reviewer. + classify_args+=(--handoff-state-dir "$HANDOFF_STATE_DIR") [ -n "$handoff_file" ] && classify_args+=(--handoff "$handoff_file") set +e "${lane_delivery[@]}" "${classify_args[@]}" delivery_rc=$? set -e + # Publish the verified episodes, then reconcile to exactly one active builder + # label from the verified current writer. Publication comes first and the + # label move is abandoned without it: the GitHub gate reads episodes only + # from trusted comments, so a moved label with no published evidence is the + # conflict this whole path exists to prevent. Historical contributions stay + # in the record; the label only says who may write next. Unresolved lineage + # publishes nothing and changes no label. + if [ "$kind" = "pr" ] && [ "$delivery_rc" -eq 0 ]; then + reconcile_head="$(jq -r '.head_sha // ""' "$after_state" 2>/dev/null || printf '')" + reconcile_branch="$(jq -r '.branch // ""' "$after_state" 2>/dev/null || printf '')" + if [ -n "$reconcile_head" ]; then + reconcile_args=( + lineage --repo "$REPO" --pr "$num" --head "$reconcile_head" + --branch "$reconcile_branch" --state-dir "$HANDOFF_STATE_DIR" + --publish --reconcile-labels --json + ) + # The label set handed to reconciliation decides what is *removed*. A + # failed read is not an empty label set: passing none makes the move + # purely additive, so the destination lane's label goes on while the + # source lane's stays, the pull request carries two builder labels, and + # the run reports success. This read is required, and it is required + # before publication -- neither the lineage comment nor the label may be + # written against a label set nobody observed. + if ! reconcile_labels="$(gh pr view "$num" -R "$REPO" \ + --json labels -q '.labels[].name' 2>/dev/null)"; then + echo "${LANE}: refusing to publish builder lineage or reconcile the builder label for ${REPO}#${num} at ${reconcile_head}: the current label set could not be read, and reconciling against an unobserved label set can leave two builder labels on the pull request. Re-run this unit once 'gh pr view --json labels' succeeds for it." >&2 + exit 2 + fi + while IFS= read -r reconcile_label; do + [ -n "$reconcile_label" ] || continue + reconcile_args+=(--label "$reconcile_label") + done <<< "$reconcile_labels" + "${lane_delivery[@]}" "${reconcile_args[@]}" > "${log%.log}.lineage.json" 2>/dev/null \ + || echo "${LANE}: builder label reconciliation did not resolve at ${reconcile_head}" >&2 + fi + fi observed_transition="$(jq -r '.delivery.transition // "unknown"' \ "${log%.log}.delivery.json" 2>/dev/null || printf 'unknown')" delivery_reason="$(jq -r '.delivery.reason // "unknown"' \