Repository navigation
PR #2071 (classify) #2660
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Pull request automation | ||
|
Check warning on line 1 in .github/workflows/pr-automation.yml
|
||
| # Prefer an exact PR number when the event carries one; otherwise identify the CI head that triggered the workflow. | ||
| # The trailing mode mirrors the route the triggering event takes, so classifier and reviewer runs are told apart at a glance. | ||
| run-name: >- | ||
| ${{ | ||
| format('{0} ({1})', | ||
| github.event.pull_request.number && format('PR #{0}', github.event.pull_request.number) | ||
| || inputs.pr-number && format('PR #{0}', inputs.pr-number) | ||
| || github.event.client_payload.pr_number && format('PR #{0}', github.event.client_payload.pr_number) | ||
| || github.event.workflow_run.pull_requests[0].number && format('PR #{0}', github.event.workflow_run.pull_requests[0].number) | ||
| || github.event.workflow_run.head_branch && format('{0} @ {1}', github.event.workflow_run.head_branch, github.event.workflow_run.head_sha) | ||
| || format('run {0}', github.run_id), | ||
| (github.event_name == 'workflow_run' || github.event_name == 'repository_dispatch' | ||
| || (github.event_name == 'workflow_dispatch' && inputs.review)) && 'review' || 'classify') | ||
| }} | ||
| on: | ||
| pull_request_target: | ||
| types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, closed, edited, labeled, unlabeled] | ||
| workflow_run: | ||
| workflows: [CI] | ||
| types: [requested, in_progress, completed] | ||
| repository_dispatch: | ||
| types: [pr-automation-classified] | ||
| workflow_dispatch: | ||
| inputs: | ||
| pr-number: | ||
| description: Pull request number to classify or review | ||
| required: true | ||
| type: number | ||
| review: | ||
| description: Run the review route instead of classification | ||
| required: true | ||
| default: false | ||
| type: boolean | ||
| force: | ||
| description: Bypass automation gates for this manual classifier or reviewer run | ||
| required: true | ||
| default: false | ||
| type: boolean | ||
| concurrency: | ||
| # Keep event routes separate while retaining every bounded pending wake-up. | ||
| # Admission revalidates current state, so stale queued work safely becomes a no-op. | ||
| group: >- | ||
| pr-automation-${{ github.event_name }}-${{ github.event.workflow_run.head_sha || | ||
| github.event.pull_request.number || inputs.pr-number || | ||
| github.event.client_payload.pr_number || | ||
| github.run_id }}-${{ github.event.label.name && | ||
| format('label-{0}', github.event.label.name) || 'pr' }} | ||
| cancel-in-progress: false | ||
| queue: max | ||
| permissions: {} | ||
| jobs: | ||
| resolve-pr: | ||
| name: Resolve pull request | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
| outputs: | ||
| ok: ${{ steps.resolve.outputs.ok }} | ||
| route: ${{ steps.resolve.outputs.route }} | ||
| pr-number: ${{ steps.resolve.outputs.pr-number }} | ||
| head-sha: ${{ steps.resolve.outputs.head-sha }} | ||
| base-sha: ${{ steps.resolve.outputs.base-sha }} | ||
| labels: ${{ steps.resolve.outputs.labels }} | ||
| author: ${{ steps.resolve.outputs.author }} | ||
| force: ${{ steps.resolve.outputs.force }} | ||
| observed-ci-run: ${{ steps.resolve.outputs.observed-ci-run }} | ||
| review-requested: ${{ steps.resolve.outputs.review-requested }} | ||
| classification-requested: ${{ steps.resolve.outputs.classification-requested }} | ||
| review-route: ${{ steps.resolve.outputs.review-route }} | ||
| classifier-lane: ${{ steps.resolve.outputs.classifier-lane }} | ||
| reviewer-pipeline-lane: ${{ steps.resolve.outputs.reviewer-pipeline-lane }} | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - id: resolve | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| PULL_REQUEST_URL_BASE: ${{ github.server_url }}/${{ github.repository }}/pull | ||
| with: | ||
| script: | | ||
| const { resolvePr } = require("./.github/pr-automation/resolve-pr"); | ||
| const result = await resolvePr({ | ||
| github, | ||
| context, | ||
| inputs: { | ||
| prNumber: context.payload.inputs?.["pr-number"], | ||
| force: context.payload.inputs?.force, | ||
| review: context.payload.inputs?.review, | ||
| }, | ||
| }); | ||
| core.setOutput("ok", result.ok === true); | ||
| core.setOutput("route", result.route ?? ""); | ||
| core.setOutput("pr-number", result.prNumber ?? ""); | ||
| core.setOutput("head-sha", result.headSha ?? ""); | ||
| core.setOutput("base-sha", result.baseSha ?? ""); | ||
| core.setOutput("labels", JSON.stringify(result.labels ?? [])); | ||
| core.setOutput("author", JSON.stringify(result.author ?? null)); | ||
| core.setOutput("force", result.force === true); | ||
| core.setOutput("observed-ci-run", result.observedCiRun ? JSON.stringify(result.observedCiRun) : ""); | ||
| core.setOutput("review-requested", result.reviewRequested === true); | ||
| core.setOutput("classification-requested", result.classificationRequested === true); | ||
| core.setOutput("review-route", result.reviewRoute === true); | ||
| core.setOutput("classifier-lane", result.prNumber ? (Number(result.prNumber) % 4) + 1 : ""); | ||
| core.setOutput("reviewer-pipeline-lane", result.prNumber ? (Number(result.prNumber) % 7) + 1 : ""); | ||
| core.info(JSON.stringify({ event: "pr-automation.resolve-pr", decision: result })); | ||
| if (result.prNumber) { | ||
| const prUrl = `${process.env.PULL_REQUEST_URL_BASE}/${result.prNumber}`; | ||
| await core.summary | ||
| .addRaw(`Resolved pull request [#${result.prNumber}](${prUrl}).`, true) | ||
| .write(); | ||
| } | ||
| if (!result.ok) core.warning(`PR automation did not start: ${result.reason}`); | ||
| classification-gate: | ||
| name: Gate pull request classification | ||
| needs: resolve-pr | ||
| if: >- | ||
| needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.resolve-pr.outputs.classification-requested == 'true' | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| checks: read | ||
| contents: read | ||
| outputs: | ||
| required: ${{ steps.gate.outputs.required }} | ||
| available: ${{ steps.gate.outputs.available }} | ||
| completed: ${{ steps.gate.outputs.completed }} | ||
| reason: ${{ steps.gate.outputs.reason }} | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - id: gate | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| FORCE: ${{ needs.resolve-pr.outputs.force }} | ||
| with: | ||
| script: | | ||
| const { resolveClassificationGate } = require("./.github/pr-automation/classification-gate"); | ||
| const decision = await resolveClassificationGate({ | ||
| github, | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| expectedSha: process.env.HEAD_SHA, | ||
| force: process.env.FORCE === "true", | ||
| }); | ||
| core.setOutput("required", decision.required); | ||
| core.setOutput("available", decision.available); | ||
| core.setOutput("completed", decision.completed === true); | ||
| core.setOutput("reason", decision.reason); | ||
| if (decision.error) core.warning(`Classification gate unavailable: ${decision.error}`); | ||
| core.info(JSON.stringify({ | ||
| event: "pr-automation.classification-gate", | ||
| decision, | ||
| })); | ||
| deterministic-analysis: | ||
| name: Calculate deterministic labels | ||
| needs: resolve-pr | ||
| if: >- | ||
| needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.resolve-pr.outputs.classification-requested == 'true' | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
| outputs: | ||
| # github-script owns the result output and overwrites it with the script return value. | ||
| analysis: ${{ steps.analyze.outputs.analysis }} | ||
| ok: ${{ steps.analyze.outputs.ok }} | ||
| size-label: ${{ steps.analyze.outputs.size-label }} | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - id: analyze | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }} | ||
| with: | ||
| script: | | ||
| const { deterministicAnalysis } = require("./.github/pr-automation/deterministic-analysis"); | ||
| const { data: pullRequest } = await github.rest.pulls.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: Number(process.env.PULL_REQUEST_NUMBER), | ||
| }); | ||
| const result = await deterministicAnalysis({ | ||
| github, | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| prNumber: pullRequest.number, | ||
| authorAssociation: pullRequest.author_association, | ||
| labelerPath: "./.github/labeler.yml", | ||
| }); | ||
| core.setOutput("analysis", JSON.stringify(result)); | ||
| core.setOutput("ok", result.ok === true); | ||
| core.setOutput("size-label", result.sizeLabel || ""); | ||
| core.info(JSON.stringify({ event: "pr-automation.deterministic-analysis", decision: result })); | ||
| classification-admission: | ||
| name: Claim classification automation | ||
| needs: [resolve-pr, classification-gate] | ||
| if: >- | ||
| always() && !cancelled() && | ||
| needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.resolve-pr.outputs.classification-requested == 'true' && | ||
| needs.classification-gate.result == 'success' && | ||
| (needs.resolve-pr.outputs.force == 'true' || | ||
| (needs.classification-gate.outputs.available == 'true' && | ||
| needs.classification-gate.outputs.required == 'true')) | ||
| runs-on: ubuntu-latest | ||
| concurrency: | ||
| group: pr-automation-mutation-${{ needs.resolve-pr.outputs.pr-number }} | ||
| cancel-in-progress: false | ||
| queue: max | ||
| permissions: | ||
| actions: read | ||
| checks: write | ||
| contents: read | ||
| pull-requests: read | ||
| outputs: | ||
| owner: ${{ steps.claim.outputs.owner }} | ||
| lease: ${{ steps.claim.outputs.lease }} | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - id: claim | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }} | ||
| FORCE: ${{ needs.resolve-pr.outputs.force }} | ||
| ALLOW_SUCCESS: ${{ needs.resolve-pr.outputs.review-requested }} | ||
| RUN_ID: ${{ github.run_id }} | ||
| RUN_ATTEMPT: ${{ github.run_attempt }} | ||
| with: | ||
| script: | | ||
| if (process.env.FORCE === "true") { | ||
| core.setOutput("owner", true); | ||
| core.setOutput("lease", ""); | ||
| return; | ||
| } | ||
| const { claimAutomaticLease } = require("./.github/pr-automation/automation-lease"); | ||
| const decision = await claimAutomaticLease({ | ||
| github, owner: context.repo.owner, repo: context.repo.repo, | ||
| kind: "classification", headSha: process.env.HEAD_SHA, | ||
| prNumber: Number(process.env.PULL_REQUEST_NUMBER), | ||
| runId: Number(process.env.RUN_ID), attempt: Number(process.env.RUN_ATTEMPT), | ||
| allowSuccess: process.env.ALLOW_SUCCESS === "true", | ||
| }); | ||
| core.setOutput("owner", decision.owner); | ||
| core.setOutput("lease", decision.lease ? JSON.stringify(decision.lease) : ""); | ||
| core.info(JSON.stringify({ event: "pr-automation.classification-admission", decision })); | ||
| fork-rate-limit: | ||
| name: Check fork automation quota | ||
| needs: resolve-pr | ||
| if: needs.resolve-pr.outputs.ok == 'true' | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
| outputs: | ||
| quota: ${{ steps.limit.outputs.quota }} | ||
| allowed: ${{ steps.limit.outputs.allowed }} | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - id: limit | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }} | ||
| AUTHOR: ${{ needs.resolve-pr.outputs.author }} | ||
| with: | ||
| script: | | ||
| const { forkRateLimit } = require("./.github/pr-automation/fork-rate-limit"); | ||
| let author = null; | ||
| let result; | ||
| try { | ||
| author = JSON.parse(process.env.AUTHOR); | ||
| const { data: pullRequest } = await github.rest.pulls.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: Number(process.env.PULL_REQUEST_NUMBER), | ||
| }); | ||
| result = await forkRateLimit({ | ||
| github, | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pr: pullRequest, | ||
| author, | ||
| }); | ||
| } catch { | ||
| result = { status: "unavailable", scope: "unknown", reason: "GitHub API unavailable" }; | ||
| } | ||
| core.setOutput("quota", JSON.stringify(result)); | ||
| core.setOutput("allowed", result.status === "allowed"); | ||
| core.info(JSON.stringify({ event: "pr-automation.fork-rate-limit", decision: result })); | ||
| semver: | ||
| name: Check public API compatibility | ||
| needs: [resolve-pr, classification-gate] | ||
| if: >- | ||
| always() && !cancelled() && | ||
| needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.resolve-pr.outputs.classification-requested == 'true' && | ||
| (needs.resolve-pr.outputs.force == 'true' || | ||
| (needs.classification-gate.outputs.available == 'true' && | ||
| needs.classification-gate.outputs.required == 'true')) | ||
| runs-on: ubuntu-latest | ||
| permissions: {} | ||
| outputs: | ||
| compatibility: ${{ steps.semver.outputs.compatibility }} | ||
| env: | ||
| CARGO_SEMVER_CHECKS_VERSION: "0.51.0" | ||
| CARGO_SEMVER_CHECKS_SHA256: "cacadafaa5cd27ae6d05a74a103266917ebab4c5e43c898360e177d4fbf06a65" | ||
| CARGO_SEMVER_CHECKS_FEATURES: "core,pdu,cliprdr,connector,acceptor,session,graphics,input,server,svc,dvc,rdpdr,rdpsnd,displaycontrol,echo,mstsgu,client,rustls,client-all,qoi,qoiz" | ||
| CARGO_SEMVER_CHECKS_DIR: /tmp/ironrdp-cargo-semver-checks-${{ github.run_id }}-${{ github.run_attempt }} | ||
| SEMVER_WORKSPACE: /tmp/ironrdp-semver-workspace-${{ github.run_id }}-${{ github.run_attempt }} | ||
| SEMVER_STATE_DIR: /tmp/ironrdp-semver-state-${{ github.run_id }}-${{ github.run_attempt }} | ||
| steps: | ||
| - id: fetch | ||
| shell: bash | ||
| env: | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| BASE_SHA: ${{ needs.resolve-pr.outputs.base-sha }} | ||
| run: | | ||
| set -euo pipefail | ||
| workspace="$SEMVER_WORKSPACE" | ||
| install -d -m 755 "$workspace" | ||
| cd "$workspace" | ||
| export GIT_CONFIG_NOSYSTEM=1 | ||
| export GIT_CONFIG_GLOBAL=/dev/null | ||
| git init . | ||
| git config --local core.hooksPath /dev/null | ||
| git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" | ||
| git fetch --no-tags origin "+$HEAD_SHA:refs/remotes/origin/pull-request-head" | ||
| git fetch --no-tags origin "+$BASE_SHA:refs/remotes/origin/pull-request-base" | ||
| git checkout --detach origin/pull-request-head | ||
| - id: install | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| sudo apt-get update -qq | ||
| sudo apt-get -y install libasound2-dev | ||
| install -d -m 700 "$CARGO_SEMVER_CHECKS_DIR" | ||
| curl --fail --location \ | ||
| "https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${CARGO_SEMVER_CHECKS_VERSION}/cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz" \ | ||
| --output /tmp/cargo-semver-checks.tar.gz | ||
| echo "$CARGO_SEMVER_CHECKS_SHA256 /tmp/cargo-semver-checks.tar.gz" | sha256sum --check --strict | ||
| tar -xzf /tmp/cargo-semver-checks.tar.gz -C "$CARGO_SEMVER_CHECKS_DIR" cargo-semver-checks | ||
| sudo chown -R nobody:nogroup "$CARGO_SEMVER_CHECKS_DIR" | ||
| sudo chmod 700 "$CARGO_SEMVER_CHECKS_DIR/cargo-semver-checks" | ||
| - id: semver | ||
| shell: bash | ||
| env: | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| BASE_SHA: ${{ needs.resolve-pr.outputs.base-sha }} | ||
| run: | | ||
| set -euo pipefail | ||
| semver_home="$SEMVER_STATE_DIR/home" | ||
| semver_cargo="$SEMVER_STATE_DIR/cargo" | ||
| semver_target="$SEMVER_STATE_DIR/target" | ||
| workspace="$SEMVER_WORKSPACE" | ||
| rustc_bin="$(rustup which rustc)" | ||
| rust_toolchain_dir="$(dirname "$(dirname "$rustc_bin")")" | ||
| rust_bin_dir="$rust_toolchain_dir/bin" | ||
| install -d -m 700 "$semver_home" "$semver_cargo" "$semver_target" | ||
| chmod -R a+rX "$workspace" | ||
| sudo chown -R nobody:nogroup "$workspace" "$semver_home" "$semver_cargo" "$semver_target" | ||
| sudo chmod o+x "$HOME" "$HOME/.rustup" "$HOME/.rustup/toolchains" | ||
| sudo chmod -R a+rX "$rust_toolchain_dir" | ||
| cd "$workspace" | ||
| # Build scripts and proc macros run as nobody, which cannot read the runner command files. | ||
| set +e | ||
| sudo -u nobody env -i \ | ||
| HOME="$semver_home" \ | ||
| CARGO_HOME="$semver_cargo" \ | ||
| PATH="$rust_bin_dir:$PATH" \ | ||
| RUSTC_BOOTSTRAP=1 \ | ||
| RUSTDOCFLAGS="-Z unstable-options --document-private-items --document-hidden-items --output-format=json --cap-lints=allow" \ | ||
| HEAD_SHA="$HEAD_SHA" \ | ||
| BASE_SHA="$BASE_SHA" \ | ||
| CARGO_SEMVER_CHECKS_DIR="$CARGO_SEMVER_CHECKS_DIR" \ | ||
| CARGO_SEMVER_CHECKS_FEATURES="$CARGO_SEMVER_CHECKS_FEATURES" \ | ||
| SEMVER_ARTIFACT_ROOT="$semver_target" \ | ||
| bash -euo pipefail -c ' | ||
| # cargo-semver-checks normally resolves a new dependency graph in a | ||
| # placeholder crate. Build rustdoc ourselves so both revisions use | ||
| # their committed lockfiles, just like the ordinary CI build. | ||
| build_rustdoc() { | ||
| # Drop anything the previous build left in the source tree. | ||
| git clean -xdfq | ||
| git checkout --detach "$2" | ||
| CARGO_TARGET_DIR="$SEMVER_ARTIFACT_ROOT/$1" cargo rustdoc --locked \ | ||
| --package ironrdp --lib --no-default-features --features "$CARGO_SEMVER_CHECKS_FEATURES" | ||
| } | ||
| build_rustdoc baseline "$BASE_SHA" | ||
| build_rustdoc current "$HEAD_SHA" | ||
| "$CARGO_SEMVER_CHECKS_DIR/cargo-semver-checks" \ | ||
| --current-rustdoc "$SEMVER_ARTIFACT_ROOT/current/doc/ironrdp.json" \ | ||
| --baseline-rustdoc "$SEMVER_ARTIFACT_ROOT/baseline/doc/ironrdp.json" | ||
| ' | ||
| status=$? | ||
| set -e | ||
| case "$status" in | ||
| 0) status=not-suspected ;; | ||
| 100) status=suspected ;; | ||
| *) exit "$status" ;; | ||
| esac | ||
| echo "compatibility={\"head_sha\":\"$HEAD_SHA\",\"status\":\"$status\"}" >> "$GITHUB_OUTPUT" | ||
| echo "::notice title=PR automation semver decision::head_sha=$HEAD_SHA status=$status" | ||
| classifier: | ||
| name: Classify pull request | ||
| needs: [resolve-pr, classification-gate, classification-admission, deterministic-analysis, fork-rate-limit] | ||
| if: >- | ||
| always() && !cancelled() && | ||
| needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.deterministic-analysis.result == 'success' && | ||
| needs.deterministic-analysis.outputs.ok == 'true' && | ||
| (needs.resolve-pr.outputs.force == 'true' || | ||
| needs.classification-admission.outputs.owner == 'true') && | ||
| (needs.resolve-pr.outputs.force == 'true' || | ||
| (needs.classification-gate.outputs.available == 'true' && | ||
| needs.classification-gate.outputs.required == 'true' && | ||
| needs.fork-rate-limit.result == 'success' && | ||
| needs.fork-rate-limit.outputs.allowed == 'true')) | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 40 | ||
| concurrency: | ||
| group: llm-classifier-${{ needs.resolve-pr.outputs.classifier-lane }} | ||
| cancel-in-progress: false | ||
| queue: max | ||
| permissions: | ||
| pull-requests: read | ||
| environment: llm-providers | ||
| outputs: | ||
| output: ${{ steps.agent.outputs.structured-output }} | ||
| failure-reason: ${{ steps.evidence.outputs.failure-reason || steps.agent.outputs.failure-reason }} | ||
| overlap-candidates: ${{ steps.overlap-candidates.outputs.manifest }} | ||
| steps: | ||
| - id: evidence | ||
| name: Fetch base and PR head without credentials | ||
| shell: bash | ||
| env: | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| BASE_SHA: ${{ needs.resolve-pr.outputs.base-sha }} | ||
| run: | | ||
| set -euo pipefail | ||
| export GIT_CONFIG_NOSYSTEM=1 | ||
| export GIT_CONFIG_GLOBAL=/dev/null | ||
| git init . | ||
| git config --local core.hooksPath /dev/null | ||
| git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" | ||
| git fetch --no-tags origin +master:refs/remotes/origin/master | ||
| git checkout --detach origin/master | ||
| if ! bash ./.github/pr-automation/fetch-pr-evidence.sh \ | ||
| "$HEAD_SHA" "$BASE_SHA"; then | ||
| reason="pull request evidence unavailable" | ||
| if test -f pr-evidence/failure-reason.txt; then | ||
| reason="$(cat pr-evidence/failure-reason.txt)" | ||
| fi | ||
| echo "failure-reason=$reason" >> "$GITHUB_OUTPUT" | ||
| exit 1 | ||
| fi | ||
| - id: overlap-candidates | ||
| name: Fetch bounded overlap candidates | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }} | ||
| with: | ||
| script: | | ||
| const fs = require("node:fs"); | ||
| const { data } = await github.rest.pulls.list({ | ||
| owner: context.repo.owner, repo: context.repo.repo, | ||
| state: "open", sort: "updated", direction: "desc", per_page: 100, | ||
| }); | ||
| const current = Number(process.env.PULL_REQUEST_NUMBER); | ||
| const candidates = data.filter((pull) => pull.number !== current).slice(0, 30) | ||
| .map((pull) => ({ | ||
| number: pull.number, | ||
| url: pull.html_url, | ||
| title: String(pull.title || "").slice(0, 300), | ||
| body: String(pull.body || "").slice(0, 1000), | ||
| head_sha: pull.head?.sha || "", | ||
| })); | ||
| fs.writeFileSync( | ||
| "pr-evidence/overlap-candidates.json", | ||
| JSON.stringify({ candidates }, null, 2), | ||
| ); | ||
| core.setOutput("manifest", JSON.stringify( | ||
| candidates.map(({ number, url }) => ({ number, url })), | ||
| )); | ||
| - name: Write classifier context | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| with: | ||
| script: | | ||
| require("node:fs").writeFileSync( | ||
| "pr-automation-context.json", | ||
| JSON.stringify({ head_sha: process.env.HEAD_SHA }, null, 2), | ||
| ); | ||
| - id: agent | ||
| continue-on-error: true | ||
| uses: ./.github/actions/openai-agent | ||
| with: | ||
| api-key: ${{ secrets.HELMCODE_GLM_API_KEY }} | ||
| base-url: https://api.helmcode.com/v1 | ||
| config-file: .github/pr-automation/agents/classifier.json | ||
| normalizer: .github/pr-automation/output-normalizer.js#normalizeClassifier | ||
| resolve-classification-state: | ||
| name: Resolve classification state | ||
| needs: [resolve-pr, classification-gate, classification-admission, deterministic-analysis, fork-rate-limit, semver, classifier] | ||
| if: always() && !cancelled() && needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.resolve-pr.outputs.classification-requested == 'true' && | ||
| (needs.resolve-pr.outputs.force == 'true' || | ||
| needs.classification-admission.outputs.owner == 'true' || | ||
| needs.classification-gate.outputs.available != 'true') && | ||
| (needs.classification-gate.outputs.required == 'true' || | ||
| needs.classification-gate.outputs.available != 'true') | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| outputs: | ||
| state: ${{ steps.resolve.outputs.state }} | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - id: resolve | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| LABELS: ${{ needs.resolve-pr.outputs.labels }} | ||
| PR_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }} | ||
| DETERMINISTIC: ${{ needs.deterministic-analysis.outputs.analysis }} | ||
| CLASSIFIER: ${{ needs.classifier.outputs.output }} | ||
| CLASSIFIER_REASON: ${{ needs.classifier.outputs.failure-reason }} | ||
| OVERLAP_CANDIDATES: ${{ needs.classifier.outputs.overlap-candidates }} | ||
| CLASSIFICATION_GATE_AVAILABLE: ${{ needs.classification-gate.outputs.available }} | ||
| CLASSIFICATION_GATE_COMPLETED: ${{ needs.classification-gate.outputs.completed }} | ||
| CLASSIFICATION_GATE_REASON: ${{ needs.classification-gate.outputs.reason }} | ||
| FORK_RATE_LIMIT: ${{ needs.fork-rate-limit.outputs.quota }} | ||
| SEMVER: ${{ needs.semver.outputs.compatibility }} | ||
| FORCE: ${{ needs.resolve-pr.outputs.force }} | ||
| LEASE: ${{ needs.classification-admission.outputs.lease }} | ||
| with: | ||
| script: | | ||
| const { resolveClassificationState } = require("./.github/pr-automation/resolve-state"); | ||
| const parse = (value, fallback) => { try { return JSON.parse(value || ""); } catch { return fallback; } }; | ||
| const state = resolveClassificationState({ | ||
| expectedSha: process.env.HEAD_SHA, | ||
| labels: parse(process.env.LABELS, []), | ||
| prNumber: Number(process.env.PR_NUMBER), | ||
| deterministic: parse(process.env.DETERMINISTIC, {}), | ||
| classifier: process.env.CLASSIFIER || "", | ||
| classifierReason: process.env.CLASSIFIER_REASON || "", | ||
| overlapCandidates: parse(process.env.OVERLAP_CANDIDATES, []), | ||
| classificationGate: { | ||
| available: process.env.CLASSIFICATION_GATE_AVAILABLE === "true", | ||
| completed: process.env.CLASSIFICATION_GATE_COMPLETED === "true", | ||
| reason: process.env.CLASSIFICATION_GATE_REASON || "", | ||
| }, | ||
| rateLimit: parse(process.env.FORK_RATE_LIMIT, {}), | ||
| semver: parse(process.env.SEMVER, {}), | ||
| force: process.env.FORCE === "true", | ||
| }); | ||
| if (process.env.FORCE !== "true") state.lease = parse(process.env.LEASE, null); | ||
| core.setOutput("state", JSON.stringify(state)); | ||
| core.info(JSON.stringify({ | ||
| event: "pr-automation.classification-state", | ||
| decision: { | ||
| ok: state.ok, mode: state.mode, failed: state.failed === true, | ||
| expectedSha: state.expectedSha, | ||
| }, | ||
| })); | ||
| review-gate: | ||
| name: Gate automated review | ||
| needs: resolve-pr | ||
| if: >- | ||
| needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.resolve-pr.outputs.review-route == 'true' | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| actions: read | ||
| checks: read | ||
| contents: read | ||
| pull-requests: read | ||
| issues: read | ||
| outputs: | ||
| gate: ${{ steps.gate.outputs.gate }} | ||
| eligible: ${{ steps.gate.outputs.eligible }} | ||
| specialist-reviewers: ${{ steps.gate.outputs.specialist-reviewers }} | ||
| required-reviewers: ${{ steps.gate.outputs.required-reviewers }} | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - id: gate | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }} | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| ROUTE: ${{ needs.resolve-pr.outputs.route }} | ||
| LABELS: ${{ needs.resolve-pr.outputs.labels }} | ||
| AUTHOR: ${{ needs.resolve-pr.outputs.author }} | ||
| FORCE: ${{ needs.resolve-pr.outputs.force }} | ||
| OBSERVED_CI_RUN: ${{ needs.resolve-pr.outputs.observed-ci-run }} | ||
| with: | ||
| script: | | ||
| const { SCHEMA_VERSION, parseCheckState } = require("./.github/pr-automation/validate-classifier"); | ||
| const { contributorEligibility, reviewPolicyEligible, reviewCount } = require("./.github/pr-automation/resolve-state"); | ||
| const { readCheckRuns } = require("./.github/pr-automation/check-runs"); | ||
| const { generation, readLatestExactHeadCiRun } = require("./.github/pr-automation/ci-state"); | ||
| const { resolveReviewerRoute } = require("./.github/pr-automation/routing"); | ||
| const prNumber = Number(process.env.PULL_REQUEST_NUMBER); | ||
| const headSha = process.env.HEAD_SHA; | ||
| const force = process.env.FORCE === "true"; | ||
| const manual = process.env.ROUTE === "dispatch"; | ||
| let resolvedLabels = []; | ||
| let author = null; | ||
| let observedRun = null; | ||
| try { resolvedLabels = JSON.parse(process.env.LABELS || "[]"); } catch {} | ||
| try { author = JSON.parse(process.env.AUTHOR || "null"); } catch {} | ||
| try { observedRun = JSON.parse(process.env.OBSERVED_CI_RUN || "null"); } catch {} | ||
| const publish = (gate, eligible = gate.ok) => { | ||
| const requiredReviewers = (gate.specialistReviewers || []).filter((reviewer) => | ||
| (reviewer === "protocol" && gate.protocolRelated === true) || | ||
| (reviewer === "skeptical" && ["medium", "high"].includes(gate.risk))); | ||
| core.setOutput("gate", JSON.stringify(gate)); | ||
| core.setOutput("eligible", eligible); | ||
| core.setOutput("specialist-reviewers", JSON.stringify(gate.specialistReviewers || [])); | ||
| core.setOutput("required-reviewers", JSON.stringify(requiredReviewers)); | ||
| core.info(JSON.stringify({ | ||
| event: "pr-automation.review-gate", | ||
| decision: gate, | ||
| })); | ||
| }; | ||
| try { | ||
| const classificationRuns = await readCheckRuns({ | ||
| github, owner: context.repo.owner, repo: context.repo.repo, ref: headSha, | ||
| checkName: "AI classification", | ||
| }); | ||
| const completed = (runs, externalId) => runs | ||
| .filter((run) => run.external_id === externalId) | ||
| .sort((left, right) => right.id - left.id)[0]; | ||
| const classification = completed(classificationRuns, `${SCHEMA_VERSION}:${headSha}`); | ||
| const classificationOwned = classification?.conclusion === "success" && | ||
| classification.app?.slug === "github-actions"; | ||
| // A check without the current machine-readable state predates this contract: fail closed | ||
| // and wait for a later classification event to rewrite it. | ||
| const protocolState = classificationOwned ? parseCheckState(classification.output?.summary) : null; | ||
| const classificationValid = classificationOwned && protocolState !== null; | ||
| const classificationCheck = classificationValid && | ||
| classification.output?.title === "Classification complete" && | ||
| protocolState.automaticReviewEligible === true; | ||
| const classificationId = classificationValid ? classification.id : null; | ||
| const legitimacyStopped = classificationOwned && | ||
| classification.output?.title === "Automation stopped"; | ||
| const protocolRelated = protocolState?.protocolRelated === true; | ||
| if (!classificationValid && (force || manual)) { | ||
| publish({ | ||
| ok: false, force, head_sha: headSha, classificationValid: false, | ||
| classificationCheck: false, legitimacyStopped, ciGreen: false, | ||
| nextReviewEligible: false, reviewAtHead: false, policyEligible: false, labels: resolvedLabels, | ||
| protocolRelated: false, risk: "unknown", specialistReviewers: [], | ||
| contributor: { status: force ? "forced" : "unavailable" }, | ||
| reason: "valid classification unavailable", | ||
| }, false); | ||
| core.setFailed("manual or forced review requires a valid classification for the current head"); | ||
| return; | ||
| } | ||
| if (force) { | ||
| const route = resolveReviewerRoute({ | ||
| suggestedReviewers: protocolState.specialistReviewers, | ||
| protocolRelated, | ||
| risk: protocolState.risk, | ||
| }); | ||
| publish({ | ||
| ok: route.ok, force: true, head_sha: headSha, classificationValid, classificationCheck, | ||
| classificationId, | ||
| legitimacyStopped, ciGreen: false, nextReviewEligible: true, | ||
| reviewAtHead: false, policyEligible: true, labels: resolvedLabels, protocolRelated, | ||
| risk: protocolState.risk, | ||
| specialistReviewers: route.ok ? route.reviewers : [], | ||
| contributor: { status: "forced" }, | ||
| }); | ||
| return; | ||
| } | ||
| const { data: issue } = await github.rest.issues.get({ | ||
| owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, | ||
| }); | ||
| const labels = issue.labels.map((label) => typeof label === "string" ? label : label.name); | ||
| const reviewRuns = await readCheckRuns({ | ||
| github, owner: context.repo.owner, repo: context.repo.repo, ref: headSha, | ||
| checkName: "AI automated review", | ||
| }); | ||
| const reviewAtHead = reviewRuns.some((run) => | ||
| run.external_id === headSha && run.conclusion === "success" && | ||
| run.app?.slug === "github-actions"); | ||
| const latestCiRun = await readLatestExactHeadCiRun({ | ||
| github, owner: context.repo.owner, repo: context.repo.repo, expectedSha: headSha, | ||
| observedRun, | ||
| }); | ||
| const ciGeneration = generation(latestCiRun); | ||
| const ciSource = latestCiRun === null ? null : latestCiRun === observedRun ? "event" : "listing"; | ||
| const ciGreen = latestCiRun?.conclusion === "success" && ciGeneration !== null; | ||
| const nextReviewEligible = reviewCount(labels) !== undefined && !reviewAtHead; | ||
| const policyEligible = reviewPolicyEligible({ labels, legitimacyStopped }); | ||
| const contributor = await contributorEligibility({ | ||
| github, owner: context.repo.owner, repo: context.repo.repo, author, | ||
| currentPrNumber: prNumber, | ||
| }); | ||
| publish({ | ||
| ok: classificationCheck && ciGreen && nextReviewEligible && policyEligible && | ||
| contributor.status === "eligible", | ||
| force: false, head_sha: headSha, classificationValid, classificationCheck, legitimacyStopped, | ||
| classificationId, | ||
| ciGreen, ciRunId: ciGeneration?.id ?? null, | ||
| ciRunAttempt: ciGeneration?.attempt ?? null, ciSource, | ||
| nextReviewEligible, reviewAtHead, policyEligible, labels, contributor, | ||
| protocolRelated, risk: protocolState?.risk || "unknown", | ||
| specialistReviewers: protocolState?.specialistReviewers || [], | ||
| }); | ||
| } catch (error) { | ||
| core.warning(`Review gate unavailable: ${error.message}`); | ||
| publish({ | ||
| ok: false, force, head_sha: headSha, classificationValid: false, classificationCheck: false, | ||
| classificationId: null, | ||
| legitimacyStopped: false, ciGreen: false, nextReviewEligible: force, | ||
| reviewAtHead: false, policyEligible: false, labels: force ? resolvedLabels : [], protocolRelated: false, | ||
| risk: "unknown", specialistReviewers: [], | ||
| contributor: force ? { status: "forced" } : { status: "unavailable" }, | ||
| reason: error.message, | ||
| }, false); | ||
| } | ||
| review-admission: | ||
| name: Claim automated review | ||
| needs: [resolve-pr, review-gate] | ||
| if: >- | ||
| always() && !cancelled() && | ||
| needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.resolve-pr.outputs.review-route == 'true' && | ||
| needs.review-gate.outputs.eligible == 'true' | ||
| runs-on: ubuntu-latest | ||
| concurrency: | ||
| group: pr-automation-mutation-${{ needs.resolve-pr.outputs.pr-number }} | ||
| cancel-in-progress: false | ||
| queue: max | ||
| permissions: | ||
| actions: read | ||
| checks: write | ||
| contents: read | ||
| issues: read | ||
| pull-requests: read | ||
| outputs: | ||
| owner: ${{ steps.claim.outputs.owner }} | ||
| lease: ${{ steps.claim.outputs.lease }} | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - id: claim | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }} | ||
| GATE: ${{ needs.review-gate.outputs.gate }} | ||
| FORCE: ${{ needs.resolve-pr.outputs.force }} | ||
| RUN_ID: ${{ github.run_id }} | ||
| RUN_ATTEMPT: ${{ github.run_attempt }} | ||
| with: | ||
| script: | | ||
| if (process.env.FORCE === "true") { | ||
| core.setOutput("owner", true); | ||
| core.setOutput("lease", ""); | ||
| return; | ||
| } | ||
| const { claimAutomaticLease } = require("./.github/pr-automation/automation-lease"); | ||
| const gate = JSON.parse(process.env.GATE || "{}"); | ||
| const decision = await claimAutomaticLease({ | ||
| github, owner: context.repo.owner, repo: context.repo.repo, | ||
| kind: "review", headSha: process.env.HEAD_SHA, | ||
| prNumber: Number(process.env.PULL_REQUEST_NUMBER), gate, | ||
| runId: Number(process.env.RUN_ID), attempt: Number(process.env.RUN_ATTEMPT), | ||
| }); | ||
| core.setOutput("owner", decision.owner); | ||
| core.setOutput("lease", decision.lease ? JSON.stringify(decision.lease) : ""); | ||
| core.info(JSON.stringify({ event: "pr-automation.review-admission", decision })); | ||
| review-pipeline: | ||
| name: Run automated review pipeline | ||
| needs: [resolve-pr, fork-rate-limit, review-gate, review-admission] | ||
| if: >- | ||
| always() && !cancelled() && | ||
| needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.resolve-pr.outputs.review-route == 'true' && | ||
| needs.review-gate.outputs.eligible == 'true' && | ||
| (needs.resolve-pr.outputs.force == 'true' || | ||
| needs.review-admission.outputs.owner == 'true') && | ||
| (needs.resolve-pr.outputs.force == 'true' || | ||
| (needs.fork-rate-limit.result == 'success' && | ||
| needs.fork-rate-limit.outputs.allowed == 'true')) | ||
| concurrency: | ||
| group: llm-reviewer-pipeline-${{ needs.resolve-pr.outputs.reviewer-pipeline-lane }} | ||
| cancel-in-progress: false | ||
| queue: max | ||
| permissions: | ||
| actions: read | ||
| checks: read | ||
| contents: read | ||
| issues: read | ||
| pull-requests: read | ||
| uses: ./.github/workflows/review-pipeline.yml | ||
| with: | ||
| pr-number: ${{ needs.resolve-pr.outputs.pr-number }} | ||
| head-sha: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| base-sha: ${{ needs.resolve-pr.outputs.base-sha }} | ||
| gate: ${{ needs.review-gate.outputs.gate }} | ||
| lease: ${{ needs.review-admission.outputs.lease }} | ||
| specialist-reviewers: ${{ needs.review-gate.outputs.specialist-reviewers }} | ||
| required-reviewers: ${{ needs.review-gate.outputs.required-reviewers }} | ||
| # Work around actions/runner#4453: environment secrets otherwise resolve empty in reusable jobs. | ||
| # This exposes caller-accessible secrets, so the called workflow must remain trusted and not execute PR code. | ||
| secrets: inherit | ||
| resolve-review-state: | ||
| name: Resolve review state | ||
| needs: [resolve-pr, fork-rate-limit, review-gate, review-admission, review-pipeline] | ||
| if: always() && !cancelled() && needs.resolve-pr.outputs.ok == 'true' && | ||
| needs.resolve-pr.outputs.review-route == 'true' && | ||
| (needs.resolve-pr.outputs.force == 'true' || | ||
| needs.review-admission.outputs.owner == 'true' || | ||
| needs.review-gate.outputs.eligible != 'true') | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - name: Download review resolver artifacts | ||
| if: needs.review-pipeline.result != 'skipped' | ||
| continue-on-error: true | ||
| uses: actions/download-artifact@v8 | ||
| with: | ||
| pattern: review-{final,report,validation,aggregate}-${{ needs.resolve-pr.outputs.head-sha }} | ||
| path: review-pipeline | ||
| merge-multiple: true | ||
| - id: resolve | ||
| uses: actions/github-script@v8 | ||
| env: | ||
| HEAD_SHA: ${{ needs.resolve-pr.outputs.head-sha }} | ||
| BASE_SHA: ${{ needs.resolve-pr.outputs.base-sha }} | ||
| GATE: ${{ needs.review-gate.outputs.gate }} | ||
| REVIEW_GATE_RESULT: ${{ needs.review-gate.result }} | ||
| FORK_RATE_LIMIT: ${{ needs.fork-rate-limit.outputs.quota }} | ||
| FORK_RATE_LIMIT_RESULT: ${{ needs.fork-rate-limit.result }} | ||
| REVIEWER_REASON: ${{ needs.review-pipeline.outputs.failure-reason }} | ||
| REVIEW_PIPELINE_RESULT: ${{ needs.review-pipeline.result }} | ||
| SUPERSEDED: ${{ needs.review-pipeline.outputs.superseded }} | ||
| FORCE: ${{ needs.resolve-pr.outputs.force }} | ||
| LABELS: ${{ needs.resolve-pr.outputs.labels }} | ||
| LEASE: ${{ needs.review-admission.outputs.lease }} | ||
| REVIEW_MARKER_ID: ${{ github.run_id }} | ||
| SUMMARY_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | ||
| with: | ||
| script: | | ||
| const fs = require("node:fs"); | ||
| const { resolveReviewState, reviewOutcome } = require("./.github/pr-automation/resolve-state"); | ||
| const { reviewSkipReasons } = require("./.github/pr-automation/review-skip-summary"); | ||
| const { parseReport } = require("./.github/pr-automation/review-report"); | ||
| const { renderReviewReport } = require("./.github/pr-automation/review-report-summary"); | ||
| const { validateFinalReview } = require("./.github/pr-automation/validate-final-review"); | ||
| const parse = (value, fallback) => { try { return JSON.parse(value || ""); } catch { return fallback; } }; | ||
| const force = process.env.FORCE === "true"; | ||
| const gate = parse(process.env.GATE, {}); | ||
| const readJson = (file) => { | ||
| try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } | ||
| }; | ||
| const persisted = readJson("review-pipeline/result.json"); | ||
| const validationContext = readJson("review-pipeline/validation-context.json"); | ||
| const specialistAggregate = readJson("review-pipeline/validated-specialist-findings.json"); | ||
| const report = parseReport(fs.existsSync("review-pipeline/review-report.json") | ||
| ? fs.readFileSync("review-pipeline/review-report.json", "utf8") | ||
| : ""); | ||
| const selectedReviewers = new Set(Array.isArray(gate.specialistReviewers) | ||
| ? gate.specialistReviewers | ||
| : []); | ||
| const aggregateReviewers = Array.isArray(specialistAggregate?.reviewers) | ||
| ? specialistAggregate.reviewers | ||
| : []; | ||
| const specialistStages = new Map(report.stages | ||
| .filter((stage) => stage.id.startsWith("specialist:")) | ||
| .map((stage) => [stage.id.slice("specialist:".length), stage])); | ||
| const artifactsAgree = | ||
| validationContext?.head_sha === process.env.HEAD_SHA && | ||
| specialistAggregate?.head_sha === process.env.HEAD_SHA && | ||
| aggregateReviewers.length === selectedReviewers.size && | ||
| aggregateReviewers.every((reviewer) => { | ||
| const stage = specialistStages.get(reviewer.reviewer); | ||
| return selectedReviewers.has(reviewer.reviewer) && | ||
| stage?.status === (reviewer.status === "valid" ? "success" : "failed"); | ||
| }) && | ||
| specialistStages.size === selectedReviewers.size; | ||
| const reducedCoverage = artifactsAgree | ||
| ? aggregateReviewers | ||
| .filter((reviewer) => reviewer.status === "failed" && | ||
| specialistStages.get(reviewer.reviewer)?.required !== true) | ||
| .map((reviewer) => reviewer.reviewer) | ||
| : []; | ||
| const failureReasons = report.stages.filter((stage) => stage.status === "failed") | ||
| .map((stage) => `${stage.id}: ${stage.reason || stage.category}`); | ||
| const reviewerReason = !artifactsAgree | ||
| ? "review artifacts disagree" | ||
| : report.status === "failed" | ||
| ? failureReasons.join("; ") || process.env.REVIEWER_REASON || "review pipeline failed" | ||
| : process.env.REVIEWER_REASON; | ||
| const revalidated = artifactsAgree && report.status === "success" && persisted | ||
| ? validateFinalReview(persisted.raw_output, { | ||
| expectedSha: process.env.HEAD_SHA, | ||
| changedPaths: validationContext.changed_paths, | ||
| changedLines: validationContext.changed_lines, | ||
| specialistAggregate, | ||
| }) | ||
| : null; | ||
| const reviewer = revalidated?.ok && | ||
| JSON.stringify(revalidated.value) === persisted.output | ||
| ? revalidated.value | ||
| : null; | ||
| const lease = parse(process.env.LEASE, null); | ||
| const state = !force && process.env.SUPERSEDED === "true" | ||
| ? { | ||
| ok: true, mode: "review", expectedSha: process.env.HEAD_SHA, | ||
| superseded: true, lease, | ||
| } | ||
| : resolveReviewState({ | ||
| expectedSha: process.env.HEAD_SHA, | ||
| labels: gate.labels ?? [], | ||
| gate, | ||
| contributor: gate.contributor ?? {}, | ||
| rateLimit: parse(process.env.FORK_RATE_LIMIT, {}), | ||
| reviewer, | ||
| reviewerReason, | ||
| force, | ||
| reviewMarkerId: process.env.REVIEW_MARKER_ID, | ||
| reducedCoverage, | ||
| reviewerValidationContext: validationContext, | ||
| specialistAggregate, | ||
| requireReviewerContext: true, | ||
| reviewAttempted: ["success", "failure"].includes(process.env.REVIEW_PIPELINE_RESULT), | ||
| }); | ||
| if (!force && !state.superseded) state.lease = lease; | ||
| core.info(JSON.stringify({ | ||
| event: "pr-automation.review-state", | ||
| decision: { | ||
| ok: state.ok, mode: state.mode, failed: state.failed === true, | ||
| expectedSha: state.expectedSha, hasReview: (state.comments || []) | ||
| .some((comment) => comment.kind === "review"), | ||
| }, | ||
| })); | ||
| if (process.env.REVIEW_PIPELINE_RESULT === "skipped") { | ||
| const reasons = reviewSkipReasons({ | ||
| gate, | ||
| gateResult: process.env.REVIEW_GATE_RESULT, | ||
| rateLimit: parse(process.env.FORK_RATE_LIMIT, null), | ||
| rateLimitResult: process.env.FORK_RATE_LIMIT_RESULT, | ||
| }); | ||
| await core.summary | ||
| .addHeading("Automated review skipped") | ||
| .addRaw("The automated review did not run because:", true) | ||
| .addList(reasons) | ||
| .write(); | ||
| } else { | ||
| const outcome = reviewOutcome({ | ||
| reportStatus: report.status, state, reducedCoverage, | ||
| }); | ||
| const rendered = renderReviewReport({ | ||
| report, outcome, summaryUrl: process.env.SUMMARY_URL, reducedCoverage, | ||
| }); | ||
| if (state.check) { | ||
| state.check.title = rendered.title; | ||
| state.check.summary = rendered.checkSummary; | ||
| state.check.conclusion = outcome === "unavailable" ? "neutral" : "success"; | ||
| } | ||
| await core.summary | ||
| .addRaw(rendered.workflowSummary, true) | ||
| .write(); | ||
| } | ||
| fs.writeFileSync("review-state.json", JSON.stringify(state)); | ||
| - name: Upload review state | ||
| if: always() | ||
| uses: actions/upload-artifact@v7 | ||
| with: | ||
| name: review-state-${{ needs.resolve-pr.outputs.head-sha }} | ||
| path: review-state.json | ||
| if-no-files-found: error | ||
| retention-days: 1 | ||
| overwrite: true | ||
| write-state: | ||
| name: Write pull request state | ||
| needs: [resolve-pr, classification-gate, classification-admission, deterministic-analysis, fork-rate-limit, semver, | ||
| classifier, resolve-classification-state, review-gate, review-admission, review-pipeline, resolve-review-state] | ||
| if: always() && !cancelled() && needs.resolve-pr.outputs.pr-number != '' | ||
| runs-on: ubuntu-latest | ||
| concurrency: | ||
| group: pr-automation-mutation-${{ needs.resolve-pr.outputs.pr-number }} | ||
| cancel-in-progress: false | ||
| queue: max | ||
| permissions: | ||
| actions: read | ||
| contents: write | ||
| pull-requests: write | ||
| issues: write | ||
| checks: write | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: master | ||
| persist-credentials: false | ||
| - name: Download review state | ||
| if: needs.resolve-review-state.result == 'success' | ||
| continue-on-error: true | ||
| uses: actions/download-artifact@v8 | ||
| with: | ||
| name: review-state-${{ needs.resolve-pr.outputs.head-sha }} | ||
| path: workflow-state/review | ||
| - uses: actions/github-script@v8 | ||
| env: | ||
| PULL_REQUEST_NUMBER: ${{ needs.resolve-pr.outputs.pr-number }} | ||
| REVIEW_REQUESTED: ${{ needs.resolve-pr.outputs.review-requested }} | ||
| OBSERVED_CI_RUN: ${{ needs.resolve-pr.outputs.observed-ci-run }} | ||
| CLASSIFICATION_STATE: ${{ needs.resolve-classification-state.outputs.state }} | ||
| UPSTREAM_RESULTS: >- | ||
| {"classification-gate":"${{ needs.classification-gate.result }}","deterministic-analysis":"${{ needs.deterministic-analysis.result }}","fork-rate-limit":"${{ needs.fork-rate-limit.result }}","semver":"${{ needs.semver.result }}","classifier":"${{ needs.classifier.result }}","resolve-classification-state":"${{ needs.resolve-classification-state.result }}","review-gate":"${{ needs.review-gate.result }}","review-pipeline":"${{ needs.review-pipeline.result }}","resolve-review-state":"${{ needs.resolve-review-state.result }}"} | ||
| with: | ||
| script: | | ||
| const fs = require("node:fs"); | ||
| const { writeState } = require("./.github/pr-automation/write-state"); | ||
| const { StaleHeadError } = require("./.github/pr-automation/current-head"); | ||
| const { reconcileLifecycle } = require("./.github/pr-automation/lifecycle"); | ||
| const parse = (value) => { try { return JSON.parse(value || ""); } catch { return null; } }; | ||
| const readState = (file) => { | ||
| try { return parse(fs.readFileSync(file, "utf8")); } catch { return null; } | ||
| }; | ||
| const state = readState("workflow-state/review/review-state.json") ?? | ||
| parse(process.env.CLASSIFICATION_STATE); | ||
| const upstreamResults = JSON.parse(process.env.UPSTREAM_RESULTS); | ||
| core.info(JSON.stringify({ | ||
| event: "pr-automation.write-state", | ||
| upstreamResults, | ||
| selectedState: state ? { | ||
| ok: state.ok, mode: state.mode, failed: state.failed === true, | ||
| expectedSha: state.expectedSha, | ||
| } : null, | ||
| })); | ||
| try { | ||
| if (!state) { | ||
| core.info("No normalized result state was available; reconciling lifecycle only"); | ||
| } else { | ||
| await writeState({ | ||
| github, owner: context.repo.owner, repo: context.repo.repo, | ||
| prNumber: Number(process.env.PULL_REQUEST_NUMBER), state, | ||
| botLogin: "github-actions[bot]", | ||
| reviewRequested: process.env.REVIEW_REQUESTED === "true", | ||
| }); | ||
| } | ||
| } catch (error) { | ||
| if (!(error instanceof StaleHeadError)) throw error; | ||
| core.info("Result publication became stale; reconciling lifecycle only"); | ||
| } finally { | ||
| const lifecycle = await reconcileLifecycle({ | ||
| github, owner: context.repo.owner, repo: context.repo.repo, | ||
| prNumber: Number(process.env.PULL_REQUEST_NUMBER), | ||
| observedRun: parse(process.env.OBSERVED_CI_RUN), | ||
| }); | ||
| core.info(JSON.stringify({ event: "pr-automation.lifecycle", lifecycle })); | ||
| } | ||
| core.info(JSON.stringify({ | ||
| event: "pr-automation.write-state-complete", | ||
| decision: state ? { expectedSha: state.expectedSha, mode: state.mode } : null, | ||
| })); | ||