diff --git a/.github/workflows/lineageweave-hourly-review-repair-quality.yml b/.github/workflows/lineageweave-hourly-review-repair-quality.yml new file mode 100644 index 000000000..ce0837a70 --- /dev/null +++ b/.github/workflows/lineageweave-hourly-review-repair-quality.yml @@ -0,0 +1,71 @@ +name: LineageWeave Hourly Review Repair Quality + +on: + pull_request: + paths: + - .github/workflows/lineageweave-hourly-review-repair.yml + - .github/workflows/lineageweave-hourly-review-repair-quality.yml + - scripts/ci/pr_review_fix_scheduler.py + - scripts/ci/pr_review_fix_stack_scheduler.py + - scripts/ci/pr_review_merge_scheduler.py + - tests/test_pr_review_fix_stack_scheduler.py + - tests/test_pr_review_fix_scheduler.py + - tests/test_pr_review_merge_scheduler.py + - tests/test_lineageweave_hourly_review_caller.py + - docs/doctoring/lineageweave-hourly-review-caller.md + - docs/doctoring/lineageweave-buyer-surface-opencode-incident.md + - requirements-opencode-review-ci-hashes.txt + +permissions: + contents: read + +concurrency: + group: lineageweave-hourly-review-quality-${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }} + cancel-in-progress: true + +jobs: + contract: + name: LineageWeave ordered-stack and least-privilege contract + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + - name: Checkout exact source revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Install hash-locked test tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + - name: Verify ordered-stack repair contracts + run: | + set -euo pipefail + python -m coverage erase + python -m coverage run --branch -m pytest -q tests + python -m coverage report \ + --include=scripts/ci/pr_review_fix_scheduler.py,scripts/ci/pr_review_fix_stack_scheduler.py,scripts/ci/pr_review_merge_scheduler.py \ + --fail-under=100 + interrogate -vv --fail-under 100 \ + scripts/ci/pr_review_fix_scheduler.py \ + scripts/ci/pr_review_fix_stack_scheduler.py \ + scripts/ci/pr_review_merge_scheduler.py + python -m compileall -q \ + scripts/ci/pr_review_fix_scheduler.py \ + scripts/ci/pr_review_fix_stack_scheduler.py \ + scripts/ci/pr_review_merge_scheduler.py \ + tests/test_lineageweave_hourly_review_caller.py \ + tests/test_pr_review_fix_stack_scheduler.py \ + tests/test_pr_review_merge_scheduler.py + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + git diff --check "$BASE_SHA...$HEAD_SHA" diff --git a/.github/workflows/lineageweave-hourly-review-repair.yml b/.github/workflows/lineageweave-hourly-review-repair.yml new file mode 100644 index 000000000..0b33d97d2 --- /dev/null +++ b/.github/workflows/lineageweave-hourly-review-repair.yml @@ -0,0 +1,195 @@ +name: LineageWeave Hourly Review Repair + +on: + schedule: + # Minute 4 avoids the start-of-hour load peak and existing product callers. + - cron: "4 * * * *" + +concurrency: + group: lineageweave-hourly-review-repair + # A later heartbeat must not cancel an in-flight lineage or buyer-surface RCA. + cancel-in-progress: false + +permissions: + contents: read + +jobs: + dispatch-review-repair: + runs-on: ubuntu-24.04 + timeout-minutes: 35 + permissions: + contents: read + id-token: write + env: + TARGET_REPOSITORY: ContextualWisdomLab/LineageWeave + ROOT_BASE_BRANCH: main + PULL_REQUEST_NUMBERS: "258,260,261,262,263,264" + MAX_PRS: "6" + OPEN_PR_SCAN_LIMIT: "1000" + MAX_DISPATCHES: "1" + RETRY_HOURS: "2" + AUTOFIX_WORKFLOW: pr-review-autofix.yml + AUTOFIX_REPOSITORY: ContextualWisdomLab/.github + steps: + - name: Harden runner + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + + - name: Validate protected source and target authority + env: + ALLOWED_TARGET_REPOSITORIES: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} + run: | + set -euo pipefail + if [ "$GITHUB_REPOSITORY" != "ContextualWisdomLab/.github" ] || + [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "::error::LineageWeave repair may run only from protected ContextualWisdomLab/.github main." + exit 1 + fi + + target_allowed=false + IFS=',' read -r -a allowed_targets <<<"${ALLOWED_TARGET_REPOSITORIES:-}" + for candidate in "${allowed_targets[@]}"; do + candidate="${candidate//[[:space:]]/}" + if [ "$candidate" = "$TARGET_REPOSITORY" ]; then + target_allowed=true + break + fi + done + if [ "$target_allowed" != "true" ]; then + echo "::error::ContextualWisdomLab/LineageWeave is absent from OPENCODE_REPOSITORY_DISPATCH_TARGETS." + exit 1 + fi + + - name: Exchange OpenCode app token for scheduler mutations + id: scheduler_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + USER_TOKEN_CONFIGURED: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '' }} + run: | + set -euo pipefail + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + + if [ "$USER_TOKEN_CONFIGURED" = "true" ]; then + echo "A configured cross-repository user token takes precedence." + mark_unavailable + exit 0 + fi + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || + [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OpenCode app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + if ! oidc_response="$( + curl -fsS --connect-timeout 10 --max-time 30 \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + echo "OpenCode app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 + fi + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "OpenCode app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 + fi + if ! token_response="$( + curl -fsS --connect-timeout 10 --max-time 30 -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" + )"; then + echo "OpenCode app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 + fi + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "OpenCode app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 + fi + + echo "::add-mask::$app_token" + { + echo "available=true" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + + - name: Checkout exact protected source revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Verify exact source and scheduler contracts + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + python3 scripts/ci/pr_review_fix_scheduler.py --self-test + python3 scripts/ci/pr_review_fix_stack_scheduler.py --self-test + git diff --check + + - name: Dispatch one missing stacked-PR review + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token }} + MUTATION_CREDENTIAL_AVAILABLE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '' || steps.scheduler_app_token.outputs.available == 'true' }} + SCHEDULER_ACTIONS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token }} + SCHEDULER_DISPATCH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token }} + SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github + run: | + set -euo pipefail + if [ "$MUTATION_CREDENTIAL_AVAILABLE" != "true" ]; then + echo "::error::An established scheduler mutation credential or exchanged OpenCode app token is required." + exit 1 + fi + + python3 scripts/ci/pr_review_merge_scheduler.py \ + --repo "$TARGET_REPOSITORY" \ + --base-branch "$ROOT_BASE_BRANCH" \ + --project-flow github-flow \ + --max-prs "$OPEN_PR_SCAN_LIMIT" \ + --stacked-only \ + --trigger-reviews \ + --review-dispatch-limit 1 \ + --branch-update-limit 0 \ + --no-enable-auto-merge \ + --merge-mode disabled \ + --no-update-branches + + - name: Dispatch one dependency-safe review repair + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token }} + MUTATION_CREDENTIAL_AVAILABLE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '' || steps.scheduler_app_token.outputs.available == 'true' }} + run: | + set -euo pipefail + if [ "$MUTATION_CREDENTIAL_AVAILABLE" != "true" ]; then + echo "::error::An established scheduler mutation credential or exchanged OpenCode app token is required." + exit 1 + fi + + args=( + --repo "$TARGET_REPOSITORY" + --base-branch "$ROOT_BASE_BRANCH" + --pull-request-numbers "$PULL_REQUEST_NUMBERS" + --max-prs "$MAX_PRS" + --max-dispatches "$MAX_DISPATCHES" + --retry-hours "$RETRY_HOURS" + --resolve-unreviewed-conflicts + --autofix-workflow "$AUTOFIX_WORKFLOW" + --autofix-repository "$AUTOFIX_REPOSITORY" + ) + python3 scripts/ci/pr_review_fix_stack_scheduler.py "${args[@]}" diff --git a/docs/doctoring/lineageweave-buyer-surface-opencode-incident.md b/docs/doctoring/lineageweave-buyer-surface-opencode-incident.md new file mode 100644 index 000000000..be63990ec --- /dev/null +++ b/docs/doctoring/lineageweave-buyer-surface-opencode-incident.md @@ -0,0 +1,91 @@ +# LineageWeave buyer-surface OpenCode incident + +검토 기준일: **2026-08-20** + +## Scope + +The affected LineageWeave buyer surface is the stacked dependency chain +**#258 → #260 → #261 → #262 → #263 → #264**. A trusted +`@opencode-agent` request on #258 produced neither a visible receipt nor a +formal exact-head review. + +## First causal boundaries + +The durable repository dispatch had already succeeded. The router then tried to +add a cosmetic `eyes` reaction before publishing the acknowledgement comment. +The target repository returned HTTP 403 for that reaction, which terminated the +source run before the receipt was written. The exact invocation ledger correctly +prevented duplicate dispatch, but the previous all-agents-existing early return +also prevented a later organization sweep from healing the missing receipt. + +The scheduled sweep had a separate availability weakness: `gh api` subprocesses +had no finite timeout, so an already-running repository query could make +executor shutdown wait indefinitely after the dispatch frontier was reached. +That weakness did not explain the already-created invocation without a receipt, +but it could prevent later LineageWeave requests from being discovered and +recovered predictably. + +`concurrency.queue: max` is not an invalid workflow property. GitHub introduced +larger concurrency queues on May 7, 2026; `queue: max` preserves up to 100 +pending runs and is compatible with an omitted or false `cancel-in-progress`. +The shared local-mention queue therefore retains `queue: max`, while exact-key +downstream wrappers keep their invocation-scoped concurrency contract. + +## Repair contract + +The central repair must: + +- retain the valid `queue: max` local concurrency contract; +- bind every `gh api` subprocess to a finite timeout; +- bound repository discovery while preserving deterministic output, fair + rotation, and repository-local failure isolation; +- publish the acknowledgement even when the cosmetic reaction is forbidden; +- recreate a missing acknowledgement for an existing exact invocation without + dispatching again; +- keep acknowledgement publication failure visible for later recovery; and +- retain exact repository, pull request, head, base, actor, source comment, and + review-only behavior binding. + +The dedicated LineageWeave hourly caller complements the mention path. Initial +OpenCode review generation remains with the mention router and organization +review/merge scheduler. The hourly caller handles only actionable exact-head +review feedback, failed-check RCA, and conflict repair. It inspects the explicit +six-PR queue in dependency order, advances only after an exact no-repair result, +waits without mutation when a child is not based on its current parent head, and +dispatches no more than one repair. + +## Stack order + +A descendant is reviewed against its declared parent head. When a parent moves, +its child is stale until the base is updated and exact-head checks and formal +reviews are regenerated. Evidence from an ancestor, predecessor head, or sibling +cannot satisfy a descendant gate. + +## Operational acceptance + +Source tests do not close this incident. After the central repair and the +LineageWeave caller reach protected `main`: + +1. post one fresh trusted mention on the current head of #258; +2. observe the sibling-repository sweep discover it; +3. observe the exact-head acknowledgement receipt; +4. observe no duplicate dispatch on the next sweep; +5. observe a formal OpenCode review or explicit fail-visible evidence; and +6. process #260 through #264 in dependency order after every parent-head change. + +Independent approval, required checks, and resolution of valid review findings +remain merge requirements. + +## APA 7th references + +GitHub, Inc. (2026, May 7). *GitHub Actions concurrency groups now allow larger +queues*. GitHub Changelog. +https://github.blog/changelog/2026-05-07-github-actions-concurrency-groups-now-allow-larger-queues/ + +GitHub, Inc. (n.d.-a). *Events that trigger workflows*. GitHub Docs. Retrieved +August 20, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows + +GitHub, Inc. (n.d.-b). *Workflow syntax for GitHub Actions*. GitHub Docs. +Retrieved August 20, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax diff --git a/docs/doctoring/lineageweave-hourly-review-caller.md b/docs/doctoring/lineageweave-hourly-review-caller.md new file mode 100644 index 000000000..65368d622 --- /dev/null +++ b/docs/doctoring/lineageweave-hourly-review-caller.md @@ -0,0 +1,211 @@ +# LineageWeave hourly review-repair caller + +검토 기준일: **2026-08-20** + +## Decision + +ContextualWisdomLab operates one protected hourly caller for +`ContextualWisdomLab/LineageWeave`. The caller covers the current buyer-surface +stack **#258 → #260 → #261 → #262 → #263 → #264**, passes that explicit queue +to a product-neutral ordered-stack repair driver, and dispatches at most one +bounded current-head repair per heartbeat. + +Initial formal OpenCode generation remains owned by the central mention router +and review/merge scheduler. The hourly caller now reuses that same product-neutral +scheduler for one bounded all-open, stacked-PR-only review dispatch before it +inspects the declared repair stack. The repair driver still never treats a +missing initial review as repair evidence. + +The caller runs at minute 4 UTC of every hour from the protected default branch. +It deliberately exposes no branch-selected `workflow_dispatch` entry point. It +does not contain product edit logic, LLM credentials, approval authority, merge +authority, or release authority. LineageWeave remains independently deployable; +privileged automation remains in `ContextualWisdomLab/.github`. + +## Buyer-visible incident + +A trusted `@opencode-agent` request on LineageWeave #258 did not produce a +visible receipt or a formal current-head OpenCode review. The durable repository +dispatch had already succeeded. The router then attempted a cosmetic `eyes` +reaction before publishing the acknowledgement. A target-repository HTTP 403 +therefore terminated the source run before the receipt was written. The exact +invocation ledger correctly prevented duplicate dispatch, but the former early +return also prevented a later sweep from healing the missing receipt. + +The scheduled sweep had a separate availability weakness: its `gh api` +subprocesses had no finite timeout, so generator shutdown could wait indefinitely +for an already-running repository request. The central repair now bounds every +request, preserves deterministic four-worker fanout, and allows a later sweep to +repair only the missing receipt without redispatch. + +## Root-cause analysis and remediation feasibility + +The reusable worker performs exact-head root-cause analysis and tests +remediation feasibility before it edits. It must: + +1. Refetch the live head, declared base, stack dependency, formal reviews, + unresolved threads, checks, changed paths, and active writer state. +2. Establish the first causal boundary rather than repeat a terminal symptom. +3. Enumerate materially distinct minimal remedies. +4. Reject remedies that lack writer authority, cross sealed paths, require + unavailable credentials or protected-setting changes, violate stack order, + cannot be verified, or do not alter the diagnosed cause. +5. Dispatch at most one feasible repair; otherwise leave the branch unchanged. + +A queued or pending check remains a merge blocker but is not itself a code +finding. An independent non-author approval remains an external authorization +gate and is never synthesized by the repair worker. The worker cannot approve, +merge, release, weaken protection, dismiss valid findings by inference, or +manufacture passing evidence. + +## Stack order + +The active buyer surface is a dependency chain, not six unrelated pull +requests: + +```text +#258 + → #260 + → #261 + → #262 + → #263 + → #264 +``` + +The driver refetches every PR in declared order. A child must target the +immediately preceding parent branch, and its base SHA must equal that parent's +exact current head. A branch-name mismatch fails closed as a structural error. +A stale base SHA is a non-mutating wait that names the required restack. The +review/merge scheduler owns branch updates; the repair caller never edits a +stale descendant against obsolete evidence. + +Only the exact no-repair result permits the driver to advance to the next child. +A draft parent, recent same-head marker, active repair, structural error, or +other blocker stops the pass. A green result from an ancestor, predecessor head, +or sibling cannot satisfy a descendant gate. + +## Cadence and concurrency + +The caller uses one repository-scoped concurrency group and +`cancel-in-progress: false`. A later heartbeat must not discard an in-flight +lineage, ontology, temporal-event, or buyer-surface root-cause analysis. + +GitHub Actions **`queue: max` is valid** concurrency syntax as of May 7, 2026. +It preserves up to 100 pending runs when `cancel-in-progress` is false or +omitted. The central mention router therefore retains `queue: max`; the +LineageWeave heartbeat itself remains non-cancelling and bounded to one repair. + +The caller sets a **two-hour same-head retry floor**. OpenCode and NVIDIA NIM +review, plus a full stacked-PR evidence pass, can legitimately exceed one hour. +Redispatching an unchanged head every hour would create duplicate writers rather +than faster remediation. + +GitHub scheduled workflows execute from the default branch and can be delayed +under Actions load. The cron expression is a heartbeat, not a real-time SLA. +Operators perform post-merge acceptance on the next protected-default-branch +heartbeat so an arbitrary branch can never become the privileged source. + +The 2026-08-20 production sweep inspected 66 repositories after its single +organization-wide review budget had already been consumed. It reached +LineageWeave later and recorded the current stacked PRs as `OpenCode review +absent`. The dedicated hourly scan therefore spends its one review budget only +on non-draft PRs whose base differs from protected `main`; direct PRs retain the +organization required-workflow path. Review publication remains centralized and +the caller receives no merge authority. + +## Credential and model boundary + +The caller keeps workflow `GITHUB_TOKEN` at `contents: read`. Its one job receives +`id-token: write`, allowing the reviewed OpenCode GitHub App exchange when an +established user token is absent. It accepts only `PR_REVIEW_MERGE_TOKEN`, +`OPENCODE_APPROVE_TOKEN`, or that exchanged app token as mutation authority. It +never uses `secrets: inherit`, receives `NVIDIA_NIM_API_KEY`, or introduces +`COPILOT_GITHUB_TOKEN`. + +Model execution remains inside the separately reviewed central worker. Before +protected-main activation, `OPENCODE_REPOSITORY_DISPATCH_TARGETS` must contain +the exact `ContextualWisdomLab/LineageWeave` repository. A missing or mismatched +target fails before a mutation credential is materialized. + +## Operational acceptance + +Source checks are necessary but do not constitute protected-main operational acceptance. +Closure requires all of the following after the central repair and this caller +reach protected `main`: + +1. Post one fresh trusted `@opencode-agent` request against the then-current + exact head of LineageWeave #258. +2. Observe the central sibling-repository sweep discover that source comment. +3. Observe a durable exact-name invocation claim and a visible receipt containing + the source-comment marker and exact head. +4. Prove that a reaction failure cannot suppress the receipt. +5. Prove that the next sweep does not redispatch the same exact request. +6. Observe the downstream OpenCode workflow publish a formal exact-head review + or explicit fail-visible evidence. +7. Run the LineageWeave hourly caller and verify that it selects no more than one + eligible exact-head repair. +8. Re-evaluate #260 through #264 in dependency order after every parent-head + movement. + +Merge still requires zero unresolved valid findings, all required exact-head +checks, and qualifying independent approval. Static workflow syntax, a dispatch +receipt, or a green status alone is not a review verdict. + +## Verification and rollback + +Machine-checkable contracts require: + +- exact `ContextualWisdomLab/LineageWeave` target and protected `main` source; +- explicit ordered queue `258,260,261,262,263,264`; +- one all-open stacked-PR review scan with a single dispatch budget; +- exact child branch and parent-head validation; +- stale-child wait without mutation; +- minute 4 hourly cadence without a branch-selected manual entry point; +- non-cancelling repository-scoped concurrency; +- at most one dispatch and a two-hour same-head retry floor; +- read-only workflow contents plus job-scoped OIDC; +- explicit scheduler-secret or app-token selection; +- absence of model, Copilot, merge, release, and target-setting authority; +- production statement and branch coverage 100% for the stack driver; +- public API docstring coverage 100%; and +- a focused pull-request-only quality workflow for caller, driver, tests, and + doctoring. A redundant branch-push trigger must not leave a cancelled + duplicate check on the same exact head. + +Rollback removes only this caller, its focused quality workflow, the product-neutral +stack driver if no other caller consumes it, its tests, and its doctoring records. +It must not remove the central mention-router repair, the organization +review/merge scheduler, or another product caller. + +## APA 7th references + +GitHub, Inc. (2026, May 7). *GitHub Actions concurrency groups now allow larger +queues*. GitHub Changelog. +https://github.blog/changelog/2026-05-07-github-actions-concurrency-groups-now-allow-larger-queues/ + +GitHub, Inc. (n.d.-a). *Events that trigger workflows*. GitHub Docs. Retrieved +August 20, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule + +GitHub, Inc. (n.d.-b). *Reuse workflows*. GitHub Docs. Retrieved August 20, +2026, from +https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows + +GitHub, Inc. (n.d.-c). *OpenID Connect reference*. GitHub Docs. Retrieved +August 20, 2026, from +https://docs.github.com/en/actions/reference/security/oidc + +MITRE. (2026). *CWE-250: Execution with unnecessary privileges*. +https://cwe.mitre.org/data/definitions/250.html + +National Institute of Standards and Technology. (2022). *Secure software +development framework (SSDF) version 1.1: Recommendations for mitigating the +risk of software vulnerabilities* (NIST Special Publication 800-218). +https://doi.org/10.6028/NIST.SP.800-218 + +NVIDIA. (n.d.). *NVIDIA NIM for large language models documentation*. Retrieved +August 20, 2026, from +https://docs.nvidia.com/nim/large-language-models/latest/ + +OpenCode. (n.d.). *OpenCode documentation*. Retrieved August 20, 2026, from +https://opencode.ai/docs/ diff --git a/scripts/ci/pr_review_fix_scheduler.py b/scripts/ci/pr_review_fix_scheduler.py old mode 100755 new mode 100644 index 2c9745d09..a5de472a8 --- a/scripts/ci/pr_review_fix_scheduler.py +++ b/scripts/ci/pr_review_fix_scheduler.py @@ -207,7 +207,7 @@ def create_fix_marker(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: [ f"{FIX_MARKER} head_sha={head_sha} epoch={int(time.time())} -->", "", - "Scheduled review-feedback autofix for this PR head.", + "Claimed scheduled review-feedback autofix for this PR head.", "", f"- Head SHA: `{head_sha}`", ] @@ -343,8 +343,8 @@ def inspect_pr( } if repair_mode == "rca": dispatch_kwargs["repair_mode"] = "rca" - dispatch_autofix(repo, pr, **dispatch_kwargs) create_fix_marker(repo, pr, dry_run=args.dry_run) + dispatch_autofix(repo, pr, **dispatch_kwargs) return "dispatch", reasons diff --git a/scripts/ci/pr_review_fix_stack_scheduler.py b/scripts/ci/pr_review_fix_stack_scheduler.py new file mode 100644 index 000000000..226b0ddd2 --- /dev/null +++ b/scripts/ci/pr_review_fix_stack_scheduler.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Dispatch at most one repair across an explicitly ordered pull-request stack.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from typing import Any + +try: + from pr_review_fix_scheduler import ( + DEFAULT_AUTOFIX_REPOSITORY, + DEFAULT_AUTOFIX_WORKFLOW, + REPO_RE, + fetch_pr, + inspect_pr, + ) +except ModuleNotFoundError: + from scripts.ci.pr_review_fix_scheduler import ( + DEFAULT_AUTOFIX_REPOSITORY, + DEFAULT_AUTOFIX_WORKFLOW, + REPO_RE, + fetch_pr, + inspect_pr, + ) + +BRANCH_RE = re.compile(r"^(?!-)[A-Za-z0-9._/-]+$") +NUMBER_RE = re.compile(r"^[1-9][0-9]*$") +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +MAX_BRANCH_NAME_LENGTH = 255 +VALID_ACTIONS = frozenset({"dispatch", "error", "skip", "wait"}) +NO_REPAIR_REASON = ( + "no current-head autofixable review, failed-check RCA, or approved merge conflict" +) + + +def parse_pull_request_numbers(raw: str, *, maximum: int) -> tuple[int, ...]: + """Return unique positive PR numbers while preserving caller order.""" + + if maximum < 1: + raise ValueError("maximum must be positive") + tokens = tuple(part.strip() for part in raw.split(",")) + if not tokens or any(not token for token in tokens): + raise ValueError("at least one pull request number is required") + numbers: list[int] = [] + seen: set[int] = set() + for token in tokens: + if not NUMBER_RE.fullmatch(token): + raise ValueError(f"invalid pull request number: {token!r}") + number = int(token) + if number in seen: + raise ValueError(f"duplicate pull request number: {number}") + seen.add(number) + numbers.append(number) + if len(numbers) > maximum: + raise ValueError( + f"pull request stack has {len(numbers)} entries; maximum is {maximum}" + ) + return tuple(numbers) + + +def _single_pull_request(repo: str, number: int) -> dict[str, Any]: + """Fetch exactly one pull request snapshot or fail closed.""" + + records = fetch_pr(repo, number) + if not isinstance(records, list): + raise RuntimeError("pull request response must be a list") + if len(records) != 1: + raise RuntimeError( + f"expected one pull request for #{number}; received {len(records)}" + ) + record = records[0] + if not isinstance(record, dict): + raise RuntimeError("pull request response item must be an object") + if type(record.get("number")) is not int or record["number"] != number: + raise RuntimeError(f"pull request response number does not match #{number}") + for field in ("baseRefName", "headRefName"): + branch_name = record.get(field) + if ( + not isinstance(branch_name, str) + or len(branch_name) > MAX_BRANCH_NAME_LENGTH + or ".." in branch_name + or not BRANCH_RE.fullmatch(branch_name) + ): + raise RuntimeError(f"pull request response has an unsafe {field}") + for field in ("baseRefOid", "headRefOid"): + if not isinstance(record.get(field), str) or not SHA_RE.fullmatch(record[field]): + raise RuntimeError(f"pull request response has an invalid {field}") + if record.get("state") != "OPEN": + raise RuntimeError(f"pull request #{number} is not open") + return record + + +def _normalize_decision( + decision: object, +) -> tuple[str, tuple[str, ...]]: + """Validate a shared scheduler decision before recording or acting on it.""" + + if not isinstance(decision, tuple) or len(decision) != 2: + return "error", ("shared scheduler returned a malformed decision",) + action, reasons = decision + if not isinstance(action, str) or action not in VALID_ACTIONS: + return "error", ("shared scheduler returned an unknown action",) + if not isinstance(reasons, (tuple, list)) or not reasons: + return "error", ("shared scheduler returned empty reasons",) + if any(not isinstance(reason, str) or not reason.strip() for reason in reasons): + return "error", ("shared scheduler returned non-string reasons",) + return str(action), tuple(reason.strip() for reason in reasons) + + +def _base_branch_error( + pr: dict[str, Any], + *, + expected_base_name: str, +) -> str | None: + """Return a structural error when a child targets the wrong parent branch.""" + + actual_name = str(pr.get("baseRefName") or "") + if actual_name == expected_base_name: + return None + return ( + f"PR #{pr.get('number')} base branch is {actual_name!r}; " + f"expected {expected_base_name!r}" + ) + + +def _stale_base_reason( + pr: dict[str, Any], + *, + expected_base_oid: str | None, +) -> str | None: + """Return a wait reason when a child is not based on the current parent head.""" + + if expected_base_oid is None: + return None + actual_oid = str(pr.get("baseRefOid") or "") + if actual_oid.lower() == expected_base_oid.lower(): + return None + return ( + f"PR #{pr.get('number')} base SHA is {actual_oid or ''}; " + f"expected parent head {expected_base_oid}; restack before descendant repair" + ) + + +def _summary( + *, + inspected: int, + dispatched: int, + pull_request_numbers: tuple[int, ...], + decisions: list[dict[str, Any]], +) -> str: + """Serialize one deterministic machine-readable stack decision summary.""" + + return json.dumps( + { + "inspected": inspected, + "autofix_dispatches": dispatched, + "stack_prs": list(pull_request_numbers), + "decisions": decisions, + }, + sort_keys=True, + ) + + +def process_stack(args: argparse.Namespace) -> int: + """Inspect the stack in order and stop after one dispatch or blocker.""" + + previous: dict[str, Any] | None = None + previous_number: int | None = None + inspected = 0 + dispatched = 0 + failed = False + decisions: list[dict[str, Any]] = [] + for number in args.pull_request_numbers: + pr: dict[str, Any] | None = None + action = "" + reasons: tuple[str, ...] = () + if previous is not None and previous_number is not None: + try: + refreshed_parent = _single_pull_request(args.repo, previous_number) + except (RuntimeError, OSError, ValueError) as exc: + action, reasons = "error", (str(exc),) + else: + if refreshed_parent["headRefOid"].lower() != previous["headRefOid"].lower(): + action, reasons = "wait", ( + f"parent PR #{previous_number} head moved from " + f"{previous['headRefOid']} to {refreshed_parent['headRefOid']}; " + "restack before descendant repair", + ) + else: + previous = refreshed_parent + if not action: + try: + pr = _single_pull_request(args.repo, number) + except (RuntimeError, OSError, ValueError) as exc: + action, reasons = "error", (str(exc),) + if not action: + expected_name = ( + args.base_branch + if previous is None + else str(previous.get("headRefName") or "") + ) + expected_oid = ( + None + if previous is None + else str(previous.get("headRefOid") or "") + ) + branch_error = _base_branch_error( + pr, + expected_base_name=expected_name, + ) + stale_reason = _stale_base_reason( + pr, + expected_base_oid=expected_oid, + ) + if branch_error is not None: + action, reasons = "error", (branch_error,) + elif stale_reason is not None: + action, reasons = "wait", (stale_reason,) + else: + local_args = argparse.Namespace(**vars(args)) + local_args.base_branch = expected_name + try: + action, reasons = _normalize_decision( + inspect_pr(args.repo, pr, local_args) + ) + except (RuntimeError, OSError, ValueError) as exc: + action, reasons = "error", (str(exc),) + + inspected += 1 + decisions.append( + {"pr": number, "action": action, "reasons": list(reasons)} + ) + print(f"PR #{number}: {action}: {'; '.join(reasons)}") + if action == "dispatch": + dispatched = 1 + break + if action == "error": + failed = True + break + if action == "wait": + break + if action != "skip" or tuple(reasons) != (NO_REPAIR_REASON,): + break + previous = pr + previous_number = number + + print( + _summary( + inspected=inspected, + dispatched=dispatched, + pull_request_numbers=args.pull_request_numbers, + decisions=decisions, + ) + ) + return 1 if failed else 0 + + +def self_test() -> int: + """Exercise parser invariants without network or repository mutation.""" + + assert parse_pull_request_numbers("1,2,3", maximum=3) == (1, 2, 3) + try: + parse_pull_request_numbers("1,1", maximum=3) + except ValueError: + pass + else: # pragma: no cover - defensive contract + raise AssertionError("duplicate PR numbers must fail") + print("stack self-test passed") + return 0 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse the ordered-stack scheduler CLI contract.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "")) + parser.add_argument("--base-branch", default=os.environ.get("DEFAULT_BRANCH", "")) + parser.add_argument( + "--pull-request-numbers", + default=os.environ.get("PULL_REQUEST_NUMBERS", ""), + ) + parser.add_argument("--max-prs", type=int, default=50) + parser.add_argument("--max-dispatches", type=int, default=1) + parser.add_argument("--retry-hours", type=int, default=24) + parser.add_argument("--resolve-unreviewed-conflicts", action="store_true") + parser.add_argument("--autofix-workflow", default=DEFAULT_AUTOFIX_WORKFLOW) + parser.add_argument("--autofix-repository", default=DEFAULT_AUTOFIX_REPOSITORY) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args(argv) + if args.self_test: + return args + if not REPO_RE.fullmatch(args.repo): + parser.error("--repo must be in OWNER/NAME form") + if not BRANCH_RE.fullmatch(args.base_branch): + parser.error("--base-branch is required and must be a safe branch name") + if args.max_prs < 1: + parser.error("--max-prs must be positive") + if args.max_dispatches != 1: + parser.error("ordered stack scheduling requires --max-dispatches 1") + if args.retry_hours < 1: + parser.error("--retry-hours must be positive") + try: + args.pull_request_numbers = parse_pull_request_numbers( + args.pull_request_numbers, + maximum=args.max_prs, + ) + except ValueError as exc: + parser.error(str(exc)) + return args + + +def main(argv: list[str] | None = None) -> int: + """Run the self-test or process one ordered stack.""" + + args = parse_args(sys.argv[1:] if argv is None else argv) + if args.self_test: + return self_test() + return process_stack(args) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 44620fcab..8ae467452 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -24,6 +24,7 @@ fragment SchedulerPullRequestFields on PullRequest { number title + state isDraft mergeable mergeStateStatus @@ -807,6 +808,7 @@ def rest_pr_node(repo: str, pr: dict[str, Any]) -> dict[str, Any]: return { "number": number, "title": pr.get("title"), + "state": str(pr.get("state") or "").upper(), "isDraft": bool(pr.get("draft")), "mergeable": pr.get("mergeable"), "mergeStateStatus": rest_merge_state, @@ -3888,6 +3890,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: default=os.environ.get("MERGE_MODE", "direct_or_auto"), ) parser.add_argument("--update-branches", action=argparse.BooleanOptionalAction, default=True) + parser.add_argument( + "--stacked-only", + action="store_true", + help="Inspect only PRs whose base differs from --base-branch", + ) parser.add_argument("--review-workflow", default="Required OpenCode Review") parser.add_argument("--security-workflow", default="Strix Security Scan") parser.add_argument( @@ -3918,6 +3925,8 @@ def main(argv: list[str]) -> int: if args.branch_update_limit < -1: raise SystemExit("--branch-update-limit must be -1 or greater") prs = fetch_pr(args.repo, args.pr_number) if args.pr_number else fetch_open_prs(args.repo, args.max_prs) + if args.stacked_only: + prs = [pr for pr in prs if pr.get("baseRefName") != args.base_branch] decisions = [] review_dispatches_used = 0 branch_updates_used = 0 diff --git a/tests/test_lineageweave_hourly_review_caller.py b/tests/test_lineageweave_hourly_review_caller.py new file mode 100644 index 000000000..98f596bed --- /dev/null +++ b/tests/test_lineageweave_hourly_review_caller.py @@ -0,0 +1,211 @@ +"""Contract tests for LineageWeave's bounded hourly review-repair caller.""" + +import re +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CALLER = REPO_ROOT / ".github/workflows/lineageweave-hourly-review-repair.yml" +QUALITY_WORKFLOW = REPO_ROOT / ".github/workflows/lineageweave-hourly-review-repair-quality.yml" +STACK_DRIVER = REPO_ROOT / "scripts/ci/pr_review_fix_stack_scheduler.py" +MERGE_DRIVER = REPO_ROOT / "scripts/ci/pr_review_merge_scheduler.py" +DOCTORING = REPO_ROOT / "docs/doctoring/lineageweave-hourly-review-caller.md" +INCIDENT = REPO_ROOT / "docs/doctoring/lineageweave-buyer-surface-opencode-incident.md" +ONE_SHOT = REPO_ROOT / ".github/workflows/one-shot-repair-lineageweave-stack.yml" + + +def _read(path: Path) -> str: + """Return one repository contract file as UTF-8 text.""" + + return path.read_text(encoding="utf-8") + + +def test_lineageweave_caller_is_hourly_bounded_and_ordered() -> None: + """The heartbeat covers all six PRs and permits one dependency-safe repair.""" + + caller = _read(CALLER) + + assert "workflow_dispatch:" not in caller + assert 'cron: "4 * * * *"' in caller + assert "group: lineageweave-hourly-review-repair" in caller + assert "cancel-in-progress: false" in caller + assert "TARGET_REPOSITORY: ContextualWisdomLab/LineageWeave" in caller + assert "ROOT_BASE_BRANCH: main" in caller + assert 'PULL_REQUEST_NUMBERS: "258,260,261,262,263,264"' in caller + assert 'MAX_PRS: "6"' in caller + assert 'MAX_DISPATCHES: "1"' in caller + assert 'OPEN_PR_SCAN_LIMIT: "1000"' in caller + assert 'RETRY_HOURS: "2"' in caller + assert "pr_review_fix_stack_scheduler.py" in caller + assert "pr_review_merge_scheduler.py" in caller + assert "--stacked-only" in caller + assert "--review-dispatch-limit 1" in caller + assert "--branch-update-limit 0" in caller + assert "--no-enable-auto-merge" in caller + assert "--merge-mode disabled" in caller + assert "--no-update-branches" in caller + assert "--pull-request-numbers \"$PULL_REQUEST_NUMBERS\"" in caller + + +def test_lineageweave_caller_is_protected_main_only_and_least_privilege() -> None: + """Only protected central main can materialize the established mutation path.""" + + caller = _read(CALLER) + + assert "contents: read" in caller + assert "id-token: write" in caller + assert 'GITHUB_REF" != "refs/heads/main"' in caller + assert "OPENCODE_REPOSITORY_DISPATCH_TARGETS" in caller + assert "PR_REVIEW_MERGE_TOKEN" in caller + assert "OPENCODE_APPROVE_TOKEN" in caller + assert "persist-credentials: false" in caller + assert "inputs.dry_run" not in caller + assert "github.token" not in caller + assert "MUTATION_CREDENTIAL_AVAILABLE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' ||" in caller + assert "--connect-timeout 10" in caller + assert "--max-time 30" in caller + assert "secrets: inherit" not in caller + assert "NVIDIA_NIM_API_KEY" not in caller + assert "COPILOT_GITHUB_TOKEN" not in caller + for forbidden in ( + "actions: write", + "contents: write", + "issues: write", + "pull-requests: write", + "statuses: write", + ): + assert forbidden not in caller + + +def test_each_mutating_dispatch_fails_closed_without_a_scheduler_credential() -> None: + """Review and repair dispatches emit the same typed credential failure.""" + + caller = _read(CALLER) + availability = ( + "MUTATION_CREDENTIAL_AVAILABLE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' ||" + ) + guard = 'if [ "$MUTATION_CREDENTIAL_AVAILABLE" != "true" ]; then' + diagnostic = ( + "::error::An established scheduler mutation credential or exchanged " + "OpenCode app token is required." + ) + + assert caller.count(availability) == 2 + assert caller.count(guard) == 2 + assert caller.count(diagnostic) == 2 + review_dispatch = caller.split( + " - name: Dispatch one missing stacked-PR review", 1 + )[1].split(" - name: Dispatch one dependency-safe review repair", 1)[0] + assert review_dispatch.index(guard) < review_dispatch.index( + "python3 scripts/ci/pr_review_merge_scheduler.py" + ) + + +def test_stack_driver_is_product_neutral_and_one_shot_is_absent() -> None: + """LineageWeave identity and PR numbers remain in the thin caller only.""" + + driver = _read(STACK_DRIVER) + merge_driver = _read(MERGE_DRIVER) + assert "ContextualWisdomLab/LineageWeave" not in driver + assert "ContextualWisdomLab/LineageWeave" not in merge_driver + for number in ("258", "260", "261", "262", "263", "264"): + assert re.search(rf"(? None: + """Doctoring retains target, credential, stack, and approval rules.""" + + doctoring = _read(DOCTORING) + + for phrase in ( + "ContextualWisdomLab/LineageWeave", + "#258 → #260 → #261 → #262 → #263 → #264", + "OPENCODE_REPOSITORY_DISPATCH_TARGETS", + "independent non-author approval", + "NVIDIA_NIM_API_KEY", + "COPILOT_GITHUB_TOKEN", + "id-token: write", + "two-hour same-head retry floor", + "root-cause analysis", + "remediation feasibility", + "protected-main operational acceptance", + "`queue: max` is valid", + "APA 7th references", + ): + assert phrase in doctoring + assert "unsupported concurrency key" not in doctoring + + +def test_incident_doctoring_tracks_current_buyer_surface_stack() -> None: + """The incident record names the live stack and end-to-end acceptance path.""" + + incident = _read(INCIDENT) + + for phrase in ( + "#258 → #260 → #261 → #262 → #263 → #264", + "@opencode-agent", + "exact invocation ledger", + "neither a visible receipt", + "formal OpenCode review", + "no duplicate dispatch", + "dependency order", + "APA 7th references", + ): + assert phrase in incident + + +def test_focused_quality_workflow_tracks_every_owned_contract() -> None: + """Caller, driver, tests, and doctoring edits rerun the focused gate.""" + + quality = _read(QUALITY_WORKFLOW) + owned_paths = ( + ".github/workflows/lineageweave-hourly-review-repair.yml", + ".github/workflows/lineageweave-hourly-review-repair-quality.yml", + "scripts/ci/pr_review_fix_scheduler.py", + "scripts/ci/pr_review_fix_stack_scheduler.py", + "scripts/ci/pr_review_merge_scheduler.py", + "tests/test_pr_review_fix_stack_scheduler.py", + "tests/test_pr_review_fix_scheduler.py", + "tests/test_pr_review_merge_scheduler.py", + "tests/test_lineageweave_hourly_review_caller.py", + "docs/doctoring/lineageweave-hourly-review-caller.md", + "docs/doctoring/lineageweave-buyer-surface-opencode-incident.md", + "requirements-opencode-review-ci-hashes.txt", + ) + + assert "\npermissions:\n contents: read\n" in quality + assert "\n push:" not in quality + assert "cancel-in-progress: true" in quality + assert "lineageweave-hourly-review-quality-${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }}" in quality + assert "persist-credentials: false" in quality + assert "fetch-depth: 0" in quality + assert "--require-hashes" in quality + assert "tests/test_pr_review_fix_stack_scheduler.py" in quality + assert "tests/test_pr_review_fix_scheduler.py" in quality + assert "tests/test_pr_review_merge_scheduler.py" in quality + assert "python -m coverage run --branch -m pytest -q tests" in quality + assert "--fail-under=100" in quality + coverage_report = quality.split("python -m coverage report", 1)[1].split( + "--fail-under=100", 1 + )[0] + for driver in ("fix_scheduler", "fix_stack_scheduler", "merge_scheduler"): + assert f"scripts/ci/pr_review_{driver}.py" in coverage_report + assert "interrogate -vv --fail-under 100" in quality + assert "python -m compileall -q \\" in quality + assert "git diff --check" in quality + assert 'git diff --check "$BASE_SHA...$HEAD_SHA"' in quality + for path in owned_paths: + assert quality.count(f" - {path}") == 1 + for forbidden in ( + "actions: write", + "contents: write", + "issues: write", + "pull-requests: write", + "secrets: inherit", + "NVIDIA_NIM_API_KEY", + "COPILOT_GITHUB_TOKEN", + ): + assert forbidden not in quality diff --git a/tests/test_pr_review_fix_scheduler.py b/tests/test_pr_review_fix_scheduler.py index 74366f686..ba41051c3 100644 --- a/tests/test_pr_review_fix_scheduler.py +++ b/tests/test_pr_review_fix_scheduler.py @@ -156,13 +156,42 @@ def test_process_queue_dispatches_same_repo_current_head(monkeypatch, capsys): assert fix.main(["--repo", "owner/repo", "--base-branch", "main", "--dry-run"]) == 0 assert calls == [ - ("dispatch", "owner/repo", 7, "pr-review-autofix.yml", "ContextualWisdomLab/.github", True, False), ("marker", "owner/repo", 7, True), + ("dispatch", "owner/repo", 7, "pr-review-autofix.yml", "ContextualWisdomLab/.github", True, False), ] payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) assert payload["autofix_dispatches"] == 1 +def test_marker_failure_prevents_untracked_dispatch(monkeypatch) -> None: + """A failed deduplication marker must stop the external dispatch.""" + + pr = make_pr() + dispatched: list[int] = [] + monkeypatch.setattr(fix, "issue_comments", lambda repo, number: []) + monkeypatch.setattr(fix, "needs_autofix", lambda item: (True, ("repair",))) + monkeypatch.setattr( + fix, + "create_fix_marker", + lambda repo, item, dry_run: (_ for _ in ()).throw( + RuntimeError("marker unavailable") + ), + ) + monkeypatch.setattr( + fix, + "dispatch_autofix", + lambda repo, item, **kwargs: dispatched.append(item["number"]), + ) + args = fix.parse_args( + ["--repo", "owner/repo", "--base-branch", "main"] + ) + + with pytest.raises(RuntimeError, match="marker unavailable"): + fix.inspect_pr("owner/repo", pr, args) + + assert dispatched == [] + + def test_autofix_context_filters_outdated_threads_and_renders_checks(): """The context helper filters stale threads and renders compact checks.""" assert context.repo_parts("owner/repo") == ("owner", "repo") diff --git a/tests/test_pr_review_fix_stack_scheduler.py b/tests/test_pr_review_fix_stack_scheduler.py new file mode 100644 index 000000000..22abbf7fa --- /dev/null +++ b/tests/test_pr_review_fix_stack_scheduler.py @@ -0,0 +1,621 @@ +"""Tests for exact ordered-stack review repair selection.""" + +from __future__ import annotations + +import argparse +import builtins +import json +import runpy + +import pytest + +from scripts.ci import pr_review_fix_stack_scheduler as stack + + +def make_pr( + number: int, + *, + base_name: str, + base_oid: str, + head_name: str, + head_oid: str, + state: str = "OPEN", +) -> dict: + """Return one minimal scheduler-shaped pull request.""" + + return { + "number": number, + "baseRefName": base_name, + "baseRefOid": base_oid, + "headRefName": head_name, + "headRefOid": head_oid, + "state": state, + } + + +def arguments(numbers: tuple[int, ...]) -> argparse.Namespace: + """Return runtime arguments consumed by the stack driver.""" + + return argparse.Namespace( + repo="ContextualWisdomLab/LineageWeave", + base_branch="main", + pull_request_numbers=numbers, + max_prs=6, + max_dispatches=1, + retry_hours=2, + resolve_unreviewed_conflicts=True, + autofix_workflow="pr-review-autofix.yml", + autofix_repository="ContextualWisdomLab/.github", + dry_run=False, + ) + + +def test_stack_driver_falls_back_to_package_scheduler(monkeypatch) -> None: + """The stack driver remains importable without its script directory.""" + + real_import = builtins.__import__ + + def import_without_script_directory( + name, + globals_=None, + locals_=None, + fromlist=(), + level=0, + ): + """Reject the flat scheduler import and delegate every other import.""" + + if name == "pr_review_fix_scheduler": + raise ModuleNotFoundError(name) + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", import_without_script_directory) + namespace = runpy.run_path( + "scripts/ci/pr_review_fix_stack_scheduler.py", + run_name="pr_review_fix_stack_scheduler_package_fallback_test", + ) + + loaded = namespace["fetch_pr"] + assert loaded.__name__ == stack.fetch_pr.__name__ + assert loaded.__code__.co_filename == stack.fetch_pr.__code__.co_filename + + +def test_parse_pull_request_numbers_preserves_order_and_rejects_ambiguity() -> None: + """The explicit queue is positive, unique, bounded, and ordered.""" + + assert stack.parse_pull_request_numbers("258, 260,261", maximum=3) == ( + 258, + 260, + 261, + ) + for raw, maximum in ( + ("", 3), + ("0", 3), + ("x", 3), + ("258,258", 3), + ("258,,260", 3), + (",258", 3), + ("258,", 3), + ("1,2", 1), + ): + with pytest.raises(ValueError): + stack.parse_pull_request_numbers(raw, maximum=maximum) + with pytest.raises(ValueError): + stack.parse_pull_request_numbers("1", maximum=0) + + +@pytest.mark.parametrize( + "response", + [ + ("not-a-list", "list"), + ([], "one pull request"), + ([None], "object"), + ([{"number": 259}], "number"), + ( + [ + { + "number": 258, + "baseRefName": "main", + "headRefName": "x" * 256, + "baseRefOid": "0" * 40, + "headRefOid": "1" * 40, + } + ], + "headRefName", + ), + ( + [ + { + "number": 258, + "baseRefName": "main", + "headRefName": "feat/root", + "baseRefOid": "0" * 39, + "headRefOid": "1" * 40, + } + ], + "baseRefOid", + ), + ], +) +def test_single_pull_request_rejects_malformed_identity( + monkeypatch, + response, +) -> None: + """Malformed API identity never reaches dependency or repair logic.""" + + monkeypatch.setattr(stack, "fetch_pr", lambda _repo, _number: response[0]) + with pytest.raises(RuntimeError, match=response[1]): + stack._single_pull_request("owner/repo", 258) + + +def test_stack_dispatches_once_in_declared_dependency_order( + monkeypatch, + capsys, +) -> None: + """A clean parent advances to the first actionable child and then stops.""" + + root = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + ) + child = make_pr( + 260, + base_name="feat/root", + base_oid="1" * 40, + head_name="feat/child", + head_oid="2" * 40, + ) + grandchild = make_pr( + 261, + base_name="feat/child", + base_oid="2" * 40, + head_name="feat/grandchild", + head_oid="3" * 40, + ) + records = {258: root, 260: child, 261: grandchild} + fetched: list[int] = [] + inspected: list[tuple[int, str]] = [] + + def fake_fetch(repo: str, number: int) -> list[dict]: + assert repo == "ContextualWisdomLab/LineageWeave" + fetched.append(number) + return [records[number]] + + def fake_inspect(repo: str, pr: dict, args: argparse.Namespace): + assert repo == "ContextualWisdomLab/LineageWeave" + inspected.append((pr["number"], args.base_branch)) + if pr["number"] == 258: + return "skip", (stack.NO_REPAIR_REASON,) + return "dispatch", ("current-head OpenCode requested changes",) + + monkeypatch.setattr(stack, "fetch_pr", fake_fetch) + monkeypatch.setattr(stack, "inspect_pr", fake_inspect) + + assert stack.process_stack(arguments((258, 260, 261))) == 0 + assert fetched == [258, 258, 260] + assert inspected == [(258, "main"), (260, "feat/root")] + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["autofix_dispatches"] == 1 + assert [decision["pr"] for decision in payload["decisions"]] == [258, 260] + + +def test_stack_waits_when_child_base_sha_is_stale(monkeypatch, capsys) -> None: + """A stale descendant waits for restacking instead of failing or mutating.""" + + root = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + ) + stale_child = make_pr( + 260, + base_name="feat/root", + base_oid="9" * 40, + head_name="feat/child", + head_oid="2" * 40, + ) + records = {258: root, 260: stale_child} + inspected: list[int] = [] + monkeypatch.setattr(stack, "fetch_pr", lambda repo, number: [records[number]]) + + def fake_inspect(repo: str, pr: dict, args: argparse.Namespace): + inspected.append(pr["number"]) + return "skip", (stack.NO_REPAIR_REASON,) + + monkeypatch.setattr(stack, "inspect_pr", fake_inspect) + + assert stack.process_stack(arguments((258, 260))) == 0 + assert inspected == [258] + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["decisions"][-1]["action"] == "wait" + assert "expected parent head" in payload["decisions"][-1]["reasons"][0] + + +def test_stack_refreshes_parent_before_child_validation(monkeypatch, capsys) -> None: + """A parent moving after inspection blocks the child without dispatch.""" + + root = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + ) + moved_root = {**root, "headRefOid": "4" * 40} + child = make_pr( + 260, + base_name="feat/root", + base_oid="1" * 40, + head_name="feat/child", + head_oid="2" * 40, + ) + fetch_counts = {258: 0, 260: 0} + + def fake_fetch(_repo: str, number: int) -> list[dict]: + fetch_counts[number] += 1 + if number == 258: + return [root if fetch_counts[number] == 1 else moved_root] + return [child] + + inspected: list[int] = [] + monkeypatch.setattr(stack, "fetch_pr", fake_fetch) + monkeypatch.setattr( + stack, + "inspect_pr", + lambda _repo, pr, _args: inspected.append(pr["number"]) + or ("skip", (stack.NO_REPAIR_REASON,)), + ) + + assert stack.process_stack(arguments((258, 260))) == 0 + assert inspected == [258] + assert fetch_counts == {258: 2, 260: 0} + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["decisions"][-1]["action"] == "wait" + assert "head moved" in payload["decisions"][-1]["reasons"][0] + + +def test_stack_fails_closed_when_same_head_parent_is_no_longer_open( + monkeypatch, capsys +) -> None: + """A closed same-head parent cannot authorize descendant repair.""" + + root = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + ) + closed_root = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + state="CLOSED", + ) + child = make_pr( + 260, + base_name="feat/root", + base_oid="1" * 40, + head_name="feat/child", + head_oid="2" * 40, + ) + fetch_counts = {258: 0, 260: 0} + + def fake_fetch(_repo: str, number: int) -> list[dict]: + fetch_counts[number] += 1 + if number == 258: + return [root if fetch_counts[number] == 1 else closed_root] + return [child] + + inspected: list[int] = [] + monkeypatch.setattr(stack, "fetch_pr", fake_fetch) + monkeypatch.setattr( + stack, + "inspect_pr", + lambda _repo, pr, _args: inspected.append(pr["number"]) + or ("skip", (stack.NO_REPAIR_REASON,)), + ) + + assert stack.process_stack(arguments((258, 260))) == 1 + assert inspected == [258] + assert fetch_counts == {258: 2, 260: 0} + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["decisions"][-1]["action"] == "error" + assert "not open" in payload["decisions"][-1]["reasons"][0] + + +def test_stack_fails_when_parent_refresh_is_invalid(monkeypatch, capsys) -> None: + """An invalid parent refresh blocks the child without inspecting it.""" + + root = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + ) + fetch_count = 0 + + def fake_fetch(_repo: str, number: int) -> list[dict]: + nonlocal fetch_count + assert number == 258 + fetch_count += 1 + return [root] if fetch_count == 1 else [] + + inspected: list[int] = [] + monkeypatch.setattr(stack, "fetch_pr", fake_fetch) + monkeypatch.setattr( + stack, + "inspect_pr", + lambda _repo, pr, _args: inspected.append(pr["number"]) + or ("skip", (stack.NO_REPAIR_REASON,)), + ) + + assert stack.process_stack(arguments((258, 260))) == 1 + assert inspected == [258] + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["decisions"][-1]["action"] == "error" + + +def test_stack_fails_closed_on_wrong_parent_branch(monkeypatch, capsys) -> None: + """A child targeting an unexpected branch is a structural contract failure.""" + + root = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + ) + wrong_child = make_pr( + 260, + base_name="feat/other", + base_oid="1" * 40, + head_name="feat/child", + head_oid="2" * 40, + ) + records = {258: root, 260: wrong_child} + monkeypatch.setattr(stack, "fetch_pr", lambda repo, number: [records[number]]) + monkeypatch.setattr( + stack, + "inspect_pr", + lambda repo, pr, args: ("skip", (stack.NO_REPAIR_REASON,)), + ) + + assert stack.process_stack(arguments((258, 260))) == 1 + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["decisions"][-1]["action"] == "error" + assert "expected 'feat/root'" in payload["decisions"][-1]["reasons"][0] + + +def test_stack_stops_on_wait_draft_and_nonrepair_skip(monkeypatch) -> None: + """An in-flight or structurally blocked parent prevents descendant repair.""" + + pr = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + ) + fetched: list[int] = [] + + def fake_fetch(repo: str, number: int) -> list[dict]: + fetched.append(number) + return [pr] + + monkeypatch.setattr(stack, "fetch_pr", fake_fetch) + monkeypatch.setattr( + stack, + "inspect_pr", + lambda repo, item, args: ("wait", ("recent autofix marker exists",)), + ) + assert stack.process_stack(arguments((258, 260))) == 0 + assert fetched == [258] + + fetched.clear() + monkeypatch.setattr( + stack, + "inspect_pr", + lambda repo, item, args: ("skip", ("draft PR",)), + ) + assert stack.process_stack(arguments((258, 260))) == 0 + assert fetched == [258] + + +def test_stack_handles_missing_pr_and_cli_contract(monkeypatch) -> None: + """Missing records and unsafe CLI values fail before descendant mutation.""" + + monkeypatch.setattr(stack, "fetch_pr", lambda repo, number: []) + assert stack.process_stack(arguments((258,))) == 1 + assert stack.main(["--self-test"]) == 0 + parsed = stack.parse_args( + [ + "--repo", + "ContextualWisdomLab/LineageWeave", + "--base-branch", + "main", + "--pull-request-numbers", + "258,260", + "--max-prs", + "2", + "--max-dispatches", + "1", + ] + ) + assert parsed.pull_request_numbers == (258, 260) + for bad in ( + [ + "--repo", + "bad repo", + "--base-branch", + "main", + "--pull-request-numbers", + "258", + ], + [ + "--repo", + "owner/repo", + "--base-branch", + "-bad", + "--pull-request-numbers", + "258", + ], + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--pull-request-numbers", + "258", + "--max-prs", + "0", + ], + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--pull-request-numbers", + "258", + "--max-dispatches", + "2", + ], + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--pull-request-numbers", + "258", + "--retry-hours", + "0", + ], + ): + with pytest.raises(SystemExit): + stack.parse_args(bad) + + +def test_stack_handles_inspection_failure_and_empty_queue( + monkeypatch, + capsys, +) -> None: + """Inspection errors fail closed and an already-empty queue reports cleanly.""" + + pr = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + ) + monkeypatch.setattr(stack, "fetch_pr", lambda repo, number: [pr]) + monkeypatch.setattr( + stack, + "inspect_pr", + lambda repo, item, args: (_ for _ in ()).throw(RuntimeError("boom")), + ) + assert stack.process_stack(arguments((258,))) == 1 + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["decisions"][0]["reasons"] == ["boom"] + + empty_args = arguments(()) + assert stack.process_stack(empty_args) == 0 + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["inspected"] == 0 + + +def test_stack_handles_external_os_error(monkeypatch, capsys) -> None: + """An external GitHub transport error fails closed with a visible reason.""" + + monkeypatch.setattr(stack, "fetch_pr", lambda _repo, _number: (_ for _ in ()).throw(OSError("network down"))) + assert stack.process_stack(arguments((258,))) == 1 + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["decisions"][0]["action"] == "error" + assert payload["decisions"][0]["reasons"] == ["network down"] + + +@pytest.mark.parametrize( + "decision", + [ + None, + ("unknown", ("reason",)), + ("skip", ()), + ("skip", "reason"), + ("skip", (None,)), + ], +) +def test_stack_rejects_malformed_shared_decisions( + monkeypatch, + capsys, + decision, +) -> None: + """Malformed shared-scheduler outputs become explicit errors.""" + + pr = make_pr( + 258, + base_name="main", + base_oid="0" * 40, + head_name="feat/root", + head_oid="1" * 40, + ) + monkeypatch.setattr(stack, "fetch_pr", lambda _repo, _number: [pr]) + monkeypatch.setattr(stack, "inspect_pr", lambda _repo, _pr, _args: decision) + + assert stack.process_stack(arguments((258,))) == 1 + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["decisions"][0]["action"] == "error" + + +def test_cli_covers_invalid_base_stack_value_and_normal_main(monkeypatch) -> None: + """CLI validation reports unsafe values and main delegates normal execution.""" + + with pytest.raises(SystemExit): + stack.parse_args( + [ + "--repo", + "owner/repo", + "--base-branch", + "", + "--pull-request-numbers", + "258", + ] + ) + with pytest.raises(SystemExit): + stack.parse_args( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--pull-request-numbers", + "bad", + ] + ) + + seen: list[tuple[int, ...]] = [] + monkeypatch.setattr( + stack, + "process_stack", + lambda args: seen.append(args.pull_request_numbers) or 7, + ) + assert ( + stack.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--pull-request-numbers", + "258", + ] + ) + == 7 + ) + assert seen == [(258,)] diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 0e71bdbe2..bfe59bab0 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -4483,6 +4483,36 @@ def test_main_limits_review_dispatches_and_branch_updates(monkeypatch, capsys): ) +def test_main_stacked_only_excludes_default_branch_pull_requests(monkeypatch): + """A dedicated stack caller spends its review budget only on stacked PRs.""" + + prs = [make_pr(number=1), make_pr(number=2, baseRefName="feature-parent")] + seen = [] + monkeypatch.setattr(sched, "fetch_open_prs", lambda repo, max_prs: prs) + monkeypatch.setattr( + sched, + "inspect_pr", + lambda _repo, pr, **_kwargs: seen.append(pr["number"]) + or sched.Decision(pr["number"], "skip", "done"), + ) + + assert ( + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--stacked-only", + ] + ) + == 0 + ) + assert seen == [2] + + def test_main_rejects_invalid_review_dispatch_limit(): with pytest.raises(SystemExit, match="--review-dispatch-limit must be -1 or greater"): sched.main( @@ -4558,6 +4588,7 @@ def fake_split_repo(repo, accepted_invalid=accepted_invalid): "5", "--pr-number", "12", + "--stacked-only", ] ) assert parsed.repo == "owner/repo" @@ -4565,6 +4596,7 @@ def fake_split_repo(repo, accepted_invalid=accepted_invalid): assert parsed.security_workflow == "Strix Security Scan" assert parsed.stale_opencode_minutes == 5 assert parsed.pr_number == 12 + assert parsed.stacked_only assert parsed.merge_mode == "direct_or_auto" assert sched.main(["--self-test"]) == 0