Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 31 additions & 20 deletions .github/PR_AUTOMATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ They fail visibly before any reviewer starts when that prerequisite is missing,
The review gate and final writer both read every page of current exact-head `CI` runs and authorize only the latest run ID and attempt.
The triggering `workflow_run` event is authoritative for its own generation, so a run listing that still lags behind it cannot hide a green run.
Both readers briefly re-list while the listing is behind instead of treating lag as staleness; a newer run ID or attempt still wins and is never retried.
The final lifecycle reconciliation receives the same observed CI event and uses bounded listing-lag retries for every other wake-up.
Workflow and per-pull-request mutation groups use non-canceling `queue: max` concurrency, so stale queued wake-ups revalidate live state instead of canceling active publication.
Model analysis fails closed when the reviewable pull request diff exceeds the applicable evidence limit.
The trusted `evidence-diff-attributes` policy represents reproducibly verified generated artifacts with binary-change markers.
The automation posts guidance on the pull request instead of invoking a model with partial evidence.
Expand Down Expand Up @@ -196,7 +198,7 @@ A model-suspected breaking change promotes `risk/low` to `risk/medium`.
Path rules can add `scope/core`, `scope/web`, `scope/ffi`, and `scope/tooling`.
The classifier controls `scope/cross-cutting`, `kind/technical-debt`, and documentation-only classification.

Automatic review runs for every non-draft pull request that passes the remaining gates.
Automatic review runs for every open, non-draft pull request that passes the remaining gates.
Every non-bot author is eligible immediately, including first-time contributors; there is no prior-merge requirement.
Automatic review requires successful CI for the exact classified head.
After the first review, a later push starts the second review when CI succeeds for that new head.
Expand All @@ -206,15 +208,18 @@ The classifier reports possible shared scope in `overlap`, using candidate title
An unavailable or invalid exact-head classification or an attempted eligible review adds only `automation-failed`.
`needs-review` means a human reviewer can act now and is the current next actor.
`needs-author-action` means the pull request author is the current next actor.
The two next-actor labels are automation-owned and mutually exclusive.
Successful reviews with findings select `needs-author-action`, while successful reviews without findings select `needs-review`.
Successful normal classification can clear stale next-actor and failure labels before it dispatches review.
Successful review clears `automation-failed`.
Blocked and duplicate review routes preserve or clear next-actor state through their resolved state without creating a failure label.
With valid classification and green CI, legitimacy triage or `ai-reviewed/2` hands the pull request to `needs-review` without another model review.
Prompt label clearing and convergence for draft, queued, and other lifecycle transitions are deferred to the supersession and reconciliation follow-up.

Bot-authored pull requests do not run automatic routes or label reconciliation.
Lifecycle reconciliation is the only writer of these mutually exclusive actor labels.
`automation-failed` is orthogonal and may coexist with `needs-author-action` only when independent exact-head CI failure requires author action.
Every successful app-owned exact-head review check stores a bounded, versioned machine-readable `findings` or `no-findings` receipt.
Reconciliation validates the check app, SHA, external ID, conclusion, receipt schema, and exact receipt keys before using that outcome.
The newest trusted successful exact-head check is canonical, including explicit forced reviews.
An existing successful check without a receipt fails closed and preserves actor labels until a compatible outcome is available.
With green exact-head CI, findings select `needs-author-action`, no findings select `needs-review`, and clean legitimacy or terminal handoff selects `needs-review`.
Missing, pending, or nonstandard terminal CI conclusions select neither actor.
Draft pull requests and active automation leases select neither actor.
Closed pull requests select neither actor, and reopening derives the actor again from current exact-head state.

Bot-authored pull requests do not run automatic routes, but lifecycle reconciliation still clears invalid actor labels.
Force mode can override policy gates for an open pull request at its current head after a trusted, valid classification for that exact head selects its reviewers.
Force mode never bypasses classification validity, evidence retrieval, output validation, filesystem restrictions, protocol citation validation, or stale-head checks.

Expand All @@ -224,12 +229,13 @@ Automatic classification and review claim their existing canonical check run bef
The in-progress check contains only a versioned ownership marker with the kind, exact head SHA, workflow run ID, and run attempt.
Admission reads all matching check history, accepts only the GitHub Actions app, and blocks while the marked owner is active.
Canonical success is absorbing for an exact head, so later automatic or forced neutral and failure states do not change its check, labels, comments, or review count.
An automatic writer must still own its latest claim before it mutates the pull request, and completes that claim to success or neutral.
Classification admission, review admission, result publication, and lifecycle reconciliation serialize through one per-pull-request mutation boundary.
Automatic review validates the live PR, exact head, CI generation, policy, authoritative classification, and review count before claiming, then validates those facts with active lease ownership after the claim and before provider work or publication.

This is deliberately at-least-once execution.
If a run crashes after provider work, a later run may repeat the model call after the owner is terminal, canceled, or missing.
A failed final check or dispatch after earlier publication can require manual repair.
The automation does not recover artifacts, store receipts or a transaction ledger, or reconcile partial publication and lifecycle state.
A duplicate automatic review marker without a trusted successful receipt, or a failure after partial publication, stops fail-closed for manual repair.
The automation does not recover artifacts or store a transaction ledger.

## Size and fork limits

Expand All @@ -245,9 +251,8 @@ Size uses the larger bucket from counted changed lines or touched files:
| `size/XXL` | 1300 or more | 50 or more |

`size/XXL` is informational and does not block classification or review.
The evidence diff limit is 1 MiB by default.
Adding `ai-review/allow-oversized` retries classification with the model runtime's maximum 4 MiB evidence limit.
Evidence above the applicable limit fails closed without sending a partial diff to a model.
The evidence diff limit is 4 MiB.
Evidence above that limit fails closed without sending a partial diff to a model.

Fork-origin pull requests share a repository-wide quota of 50 pull requests per UTC day.
`OWNER` and `MEMBER` pull requests are exempt and do not count toward the quota.
Expand All @@ -257,9 +262,9 @@ Same-repository pull requests are also exempt.

SHA-bound GitHub checks carry classification and review state between permission-isolated jobs.
Workflow artifacts carry evidence and validated results between review-pipeline jobs.
The final writer serializes its mutations per pull request.
The final writer serializes its mutations per pull request and then reconciles actor labels from a fresh live snapshot.
Workflow-level cancellation retains the established event and label grouping.
The final writer rechecks the current head, current review policy, and, for successful non-forced review or handoff, the latest exact-head CI generation.
The final writer rechecks the current head, current review policy, authoritative classification, lease, review count, and, for successful non-forced review or handoff, the latest exact-head CI generation.
Model-execution jobs have read-only or empty permissions.
The run summary links the pull request the run resolved.

Expand All @@ -278,9 +283,15 @@ Detailed failure reasons appear only in the workflow summary.
Every failed stage is reported, not only the first one.
A mandatory specialist failure, invalid aggregate, invalid final review, exhausted limit, provider failure, or unavailable evidence applies only `automation-failed`.
Stale heads stop publication without mutation.
Failed reviews do not increment the automated review count.
Failed reviews do not increment the automated review count, and successful reviews transition removal-first to exactly one count label so an interrupted transition undercounts rather than overcounts and may require manual repair.
Cancelled runs do not publish fallback state.
This change intentionally provides no immediate lifecycle clearing when classification starts, CI starts, a pull request becomes draft, or another automation event is queued.
Events are wake-up and identity hints rather than actor-label transitions.
Classification and review result resolvers provide facts but do not write actor-label deltas.
An admitted automatic classifier that becomes draft publishes nothing, and a later ready event may retry it.
An admitted automatic review may publish its validated review, successful check, and review count while draft when its exact-head guards still pass, but it publishes no actor label.
An admitted automatic review may publish the same validated result on a closed unmerged pull request when its exact-head guards still pass, so reopening can reconcile from the receipt.
Merged pull requests and changed heads suppress publication.
If GitHub rejects review creation on a closed unmerged pull request, the lease completes neutral without publishing partial review state.

## Configuration and upgrades

Expand Down
88 changes: 75 additions & 13 deletions .github/pr-automation/automation-lease.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const { readCheckRuns } = require("./check-runs");
const { matchesGeneration, readLatestExactHeadCiRun } = require("./ci-state");
const { reviewCount } = require("./resolve-state");
const { reviewPolicyEligible } = require("./routing");
const { isClosedUnmerged, isOpenNonDraftAtHeadNow } = require("./current-head");
const { parseCheckState } = require("./validate-classifier");

const APP = "github-actions";
const LEASE_PREFIX = "<!-- ironrdp-pr-automation:lease:";
Expand Down Expand Up @@ -49,8 +51,25 @@ async function ownerIsActive(github, lease) {
}
}
async function claimAutomaticLease({
github, owner, repo, kind, headSha, runId, attempt, allowSuccess = false,
github, owner, repo, kind, headSha, runId, attempt, allowSuccess = false, prNumber, gate,
}) {
const eligible = async (lease) => {
if (prNumber === undefined) return true;
if (kind === "review") {
return await recheckAutomaticReview({
github, owner, repo, prNumber, headSha, gate, lease,
phase: lease ? "admission" : "preclaim",
});
}
return await isOpenNonDraftAtHeadNow({
github, owner, repo, pullNumber: prNumber, expectedHeadSha: headSha,
});
};
if (prNumber !== undefined) {
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
return { owner: false, available: false, reason: "pull request identity is unavailable" };
}
}
const runs = await readCanonicalRuns({ github, owner, repo, kind, headSha });
if (!runs) return { owner: false, available: false, reason: "canonical check is ambiguous" };
if (!allowSuccess && runs.some((run) => run.conclusion === "success")) {
Expand All @@ -68,13 +87,27 @@ async function claimAutomaticLease({
}
const marker = leaseMarker({ kind, headSha, runId, attempt });
const spec = CHECKS[kind];
// Re-read immediately before the consequential claim rather than relying on the admission read.
if (!await eligible()) {
return { owner: false, available: true, reason: "pull request is no longer eligible" };
}
const { data: claim } = await github.rest.checks.create({
owner, repo, name: spec.name, head_sha: headSha, external_id: spec.externalId(headSha),
status: "in_progress", output: { title: "Automation in progress", summary: marker },
});
const lease = { kind, headSha, runId, attempt, checkRunId: claim.id, marker };
// An eligibility change during `create` makes this claim unusable. Complete only our new claim
// so a later admission may proceed without inheriting an active-looking lease.
if (!await eligible(lease)) {
await github.rest.checks.update({
owner, repo, check_run_id: claim.id, status: "completed", conclusion: "neutral",
output: { title: "Automation superseded", summary: marker },
});
return { owner: false, available: true, reason: "pull request changed during lease claim" };
}
return {
owner: true, available: true,
lease: { kind, headSha, runId, attempt, checkRunId: claim.id, marker },
lease,
};
}

Expand All @@ -92,19 +125,36 @@ async function ownsActiveLease({ github, owner, repo, lease }) {
return await ownsLatestLease({ github, owner, repo, lease }) && await ownerIsActive(github, { ...lease, owner, repo });
}

async function hasCurrentClassification({ github, owner, repo, headSha, classificationId, requireValid = false }) {
const classifications = await readCanonicalRuns({
github, owner, repo, kind: "classification", headSha,
});
const classification = classifications?.[0];
return classification?.id === classificationId && classification?.conclusion === "success" &&
(!requireValid || parseCheckState(classification.output?.summary) !== null);
}

async function recheckAutomaticReview({
github, owner, repo, prNumber, headSha, gate, lease,
github, owner, repo, prNumber, headSha, gate, lease, phase = "admission",
allowDraft = false, allowClosedUnmerged = false,
}) {
if (![
"preclaim", "admission", "transition-old", "transition-empty", "transition-next",
"receipt", "cleanup",
].includes(phase)) throw new Error("invalid review recheck phase");
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
if (pull.state !== "open" || pull.draft || pull.head?.sha !== headSha) return false;
if (!await ownsLatestLease({ github, owner, repo, lease })) return false;
if ((pull.state !== "open" && !(allowClosedUnmerged && isClosedUnmerged(pull))) ||
(!allowDraft && pull.draft) || pull.head?.sha !== headSha) return false;
const reviews = await readCanonicalRuns({ github, owner, repo, kind: "review", headSha });
if (!reviews || reviews.some((run) => run.conclusion === "success")) return false;
const classifications = await readCanonicalRuns({
github, owner, repo, kind: "classification", headSha,
});
const classification = classifications?.find((run) => run.id === gate.classificationId);
if (!classification || classification.conclusion !== "success") return false;
if (!reviews || !await hasCurrentClassification({
github, owner, repo, headSha, classificationId: gate.classificationId,
})) return false;
if (phase === "cleanup") {
if (reviews[0]?.id !== lease?.checkRunId || reviews[0]?.conclusion !== "success") return false;
} else if (phase !== "preclaim") {
if (!lease || !await ownsActiveLease({ github, owner, repo, lease })) return false;
}
if (phase !== "cleanup" && reviews.some((run) => run.conclusion === "success")) return false;
const currentCi = await readLatestExactHeadCiRun({
github, owner, repo, expectedSha: headSha,
expectedGeneration: { id: gate.ciRunId, attempt: gate.ciRunAttempt },
Expand All @@ -113,7 +163,19 @@ async function recheckAutomaticReview({
!matchesGeneration(currentCi, gate.ciRunId, gate.ciRunAttempt)) return false;
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber });
const labels = issue.labels.map((label) => typeof label === "string" ? label : label.name);
return reviewCount(labels) === reviewCount(gate.labels || []) &&
const count = reviewCount(labels);
if (count === undefined) return false;
if (phase === "transition-empty") {
return count === null &&
reviewPolicyEligible({ labels, legitimacyStopped: labels.includes("triage/legitimacy") }) ===
gate.policyEligible &&
labels.includes("triage/legitimacy") === gate.legitimacyStopped;
}
if (["transition-next", "receipt", "cleanup"].includes(phase)) {
return count === gate.nextCount;
}
const admittedCount = reviewCount(gate.labels || []);
return admittedCount !== undefined && count === admittedCount &&
reviewPolicyEligible({ labels, legitimacyStopped: labels.includes("triage/legitimacy") }) ===
gate.policyEligible &&
labels.includes("triage/legitimacy") === gate.legitimacyStopped;
Expand All @@ -122,5 +184,5 @@ async function recheckAutomaticReview({
module.exports = {
CHECKS, canonicalRuns, claimAutomaticLease, leaseMarker, ownerIsActive,
ownsActiveLease, ownsLatestLease,
parseLeaseMarker, recheckAutomaticReview,
hasCurrentClassification, parseLeaseMarker, recheckAutomaticReview,
};
Loading
Loading