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
6 changes: 3 additions & 3 deletions apps/hub/src/github/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ export function mailPayload(record: Pick<RepoRecord, "name" | "source">, policy:
};
}

/** The mail for a head held for its CI: triage marks it waiting where it writes the verdict, and triages nothing. */
export function waitingPayload(record: Pick<RepoRecord, "name" | "source">, policy: RepoPolicy, pack: StoredPack, head: { number: number; headSha: string }) {
return { ...mailPayload(record, policy, pack, { prNumber: head.number, headSha: head.headSha }), kind: WAITING_KIND };
/** The mail for a head held for its CI: triage marks it waiting where it writes the verdict, and triages nothing. `noCi` names a head with no checks yet. */
export function waitingPayload(record: Pick<RepoRecord, "name" | "source">, policy: RepoPolicy, pack: StoredPack, head: { number: number; headSha: string }, noCi: boolean) {
return { ...mailPayload(record, policy, pack, { prNumber: head.number, headSha: head.headSha, ...(noCi && { noCi }) }), kind: WAITING_KIND };
}

export async function resolvedPack(
Expand Down
8 changes: 8 additions & 0 deletions apps/hub/src/github/coalescer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,14 @@ test("a head held for CI where triage writes labels is mailed to be marked waiti
expect(mails.map((mail) => mail.kind)).toEqual(["waiting", "pr"]);
});

test("a head with no checks yet is mailed to be marked waiting as having no CI", async () => {
const { clock, mails, coalescer } = harness([], { headChecks: async () => [] });
const pack = { ...emptyPack(REPO), checks: { "ci-green": { enabled: true } } };
await coalescer.accept({ ...delivery("updated", "sha2"), pack, automatic: ["labels" as const] });
await clock.advance(WINDOW.burstMs);
expect(mails).toEqual([expect.objectContaining({ kind: "waiting", headSha: "sha2", noCi: true })]);
});

function suiteCompleted(headSha: string): CoalescedEvent {
const input = delivery("checks", headSha);
return { ...input, mail: { ...input.mail, event: "check_suite" } };
Expand Down
6 changes: 3 additions & 3 deletions apps/hub/src/github/coalescer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,10 @@ export function createCoalescer(deps: CoalescerDeps): Coalescer {
}

/** The waiting marker is only shown, so a mail that did not go out is logged and the head still waits. */
async function markWaiting({ latest, head }: Open): Promise<void> {
async function markWaiting({ latest, head }: Open, noCi: boolean): Promise<void> {
if (!marksWaiting(sourceOf(latest.record), latest.automatic)) return;
try {
await deps.sendMail({ ...head, workflow: latest.workflow, source: "event", payload: waitingPayload(latest.record, latest.policy, latest, head) });
await deps.sendMail({ ...head, workflow: latest.workflow, source: "event", payload: waitingPayload(latest.record, latest.policy, latest, head, noCi) });
} catch (err) {
deps.log({ level: "warn", msg: "waiting_mail_failed", tenantId: head.tenantId, repo: latest.record.name, pr: head.number, headSha: head.headSha, error: String(err) });
}
Expand Down Expand Up @@ -207,7 +207,7 @@ export function createCoalescer(deps: CoalescerDeps): Coalescer {
if (readsCi && waitsForCi(ci, firstSeenAt, deps.now(), deps.ciWait)) {
await waitHead(deps.store, head, events, deps.now());
deps.log({ level: "info", msg: "triage_waiting_ci", ...entry, events, ci });
if (prior?.status !== "waiting") await markWaiting(open);
if (prior?.status !== "waiting") await markWaiting(open, ci === "none");
return;
}
try {
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/src/github/triage-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export function createTriageReconciler(deps: TriageReconcilerDeps) {
if (!marksWaiting(sourceOf(repo.record), repo.automatic)) return;
const waited = new Set(repo.stored.rows.filter((row) => row.status === "waiting").map(headKey));
for (const row of rows.filter((candidate) => candidate.status === "waiting" && !waited.has(headKey(candidate)))) {
await deps.send({ tenantId: pass.tenantId, workflow: triageWorkflowFor(deps.source).id, repo: triageStateKey(repo.record), number: row.number, headSha: row.headSha, source: "reconcile", payload: waitingPayload(repo.record, repo.policy, repo, row) });
await deps.send({ tenantId: pass.tenantId, workflow: triageWorkflowFor(deps.source).id, repo: triageStateKey(repo.record), number: row.number, headSha: row.headSha, source: "reconcile", payload: waitingPayload(repo.record, repo.policy, repo, row, false) });
}
}

Expand Down
12 changes: 10 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ name per tenant; no deployment ids are configured anywhere.
## Triage pipeline

Every run is the same steps; the repository's policy decides what each one
does.
does. Before facts, a pull request mail from the hub naming a GitHub head
mirrors `unready:<owner>--<name>:<number>:<head sha>`, which takes the pass
label off that head, so it never stands on a head not yet triaged.

1. **facts**: reads the pull request, its files, reviews, CI, commits (the
first 100 messages in full), the repository's docs and its contribution
Expand Down Expand Up @@ -188,7 +190,13 @@ does.
waiting mail skips every other step: `markWaiting` writes only the waiting
label or `INPROGRESS` build status under the effect id
`waiting:<owner>--<name>:<number>:<head sha>`, and nothing once the pull
request moved past that head.
request moved past that head. On GitHub the marker passes the fail and
unsure verdict labels as `keep`, so a pull request carrying one keeps its
labels until the verdict, which swaps them only when it differs. A head
with no checks (the coalescer's `noCi` on the waiting mail, or facts'
`checks: "none"` in a run) gets no waiting label: the marker only takes the
pass label off. The portal reads the wait from the head's triage-state
row, not the label.
8. **execute**: runs the actions whose kind the `pr-triage` role holds on the
repository. Every other action is held in the verdict (`held: true`).

Expand Down
6 changes: 3 additions & 3 deletions docs/Corbits Triage User Manual/Corbits Triage User Manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,9 @@ Under Code review, **Actions Triage takes on its own** lists each action with it

Enabling a repository switches on Comment and Add labels (Comment only on Bitbucket, which has no labels); every other action waits for a person to click until you switch it on, so you can watch what Triage suggests before you let it act. Turning triage off and on again switches them back on. Two actions are always held for a person: Close on Unsure, and Merge on anything but Pass.

When the policy depends on CI (the **CI must pass** rule is on, a step has a CI condition, or a step approves or merges), Triage waits for a pull request's CI to finish before it checks it, so it gives one result instead of an early Unsure. Meanwhile the Inbox shows **Waiting for CI** (**Waiting for CI · new commits since Triage checked** when an earlier result is still on show). A pull request that already needed you stays in Needs you meanwhile; one Triage has not checked yet waits under **Waiting**. The pull request's page notes **New commits since Triage checked** above the earlier result, which stays until the new one lands. The pull request itself shows **Triage: Waiting for CI**, as a label on GitHub where Triage adds labels on its own and as the **Corbits Triage** build status on Bitbucket where Triage comments on its own. The verdict replaces it once Triage has checked the pull request. Triage goes ahead as soon as any check fails, after two minutes when the repository has no CI, and after an hour at most. **Triage again** does not wait.
When the policy depends on CI (the **CI must pass** rule is on, a step has a CI condition, or a step approves or merges), Triage waits for a pull request's CI to finish before it checks it, so it gives one result instead of an early Unsure. Meanwhile the Inbox shows **Waiting for CI** (**Waiting for CI · new commits since Triage checked** when an earlier result is still on show). A pull request that already needed you stays in Needs you meanwhile; one Triage has not checked yet waits under **Waiting**. The pull request's page notes **New commits since Triage checked** above the earlier result, which stays until the new one lands. On GitHub, where Triage adds labels on its own, a pull request labelled **Triage: Not Ready** or **Triage: Needs Human Review** keeps it, so new commits change no label unless the verdict changes; any other, **Triage: Ready** included, shows **Triage: Waiting for CI**, or no Triage label while the commit has no checks. On Bitbucket, where Triage comments on its own, the new commit's **Corbits Triage** build status shows **Triage: Waiting for CI**. The verdict replaces it once Triage has checked the pull request. Triage goes ahead as soon as any check fails, after two minutes when the repository has no CI, and after an hour at most. **Triage again** does not wait.

If Triage checks a pull request anyway while the only rules it cannot confirm yet are waiting on the host (CI still running, or the host has not yet worked out whether the pull request can merge), it gives no verdict yet: the pull request keeps **Triage: Waiting for CI**, nothing is posted and no action runs, and Triage checks it again once they are done. After an hour, on **Triage again**, or when the host still has not worked out mergeability after Triage's last retry, it gives the verdict anyway. If a step depends on CI that is still running, Triage holds every action, and its comment on the pull request lists them under **Waiting for checks to finish**. They go ahead once the checks are done.
If Triage checks a pull request anyway while the only rules it cannot confirm yet are waiting on the host (CI still running, or the host has not yet worked out whether the pull request can merge), it gives no verdict yet: the pull request keeps its label or build status as above, nothing is posted and no action runs, and Triage checks it again once they are done. After an hour, on **Triage again**, or when the host still has not worked out mergeability after Triage's last retry, it gives the verdict anyway. If a step depends on CI that is still running, Triage holds every action, and its comment on the pull request lists them under **Waiting for checks to finish**. They go ahead once the checks are done.

An **Unsure** comment lists each rule Triage could not confirm, with checks still running under **Waiting on**.

Expand Down Expand Up @@ -369,7 +369,7 @@ Anything you do here is also written to the audit log.

### What the author sees

Authors do not use the portal. Depending on the repository's actions, they see a comment from Triage on their pull request with the verdict and reasons, review requests, and on GitHub labels such as **Triage: Ready**, **Triage: Not Ready** or **Triage: Needs Human Review** and assignments. On Bitbucket they see the verdict as the **Corbits Triage** build status instead of a label. While Triage waits for CI, they see **Triage: Waiting for CI** in its place. Triage keeps one comment and edits it in place as the pull request changes, so an author who pushes a fix sees the fresh verdict in the same comment. Each change Triage asks to split out names what it is not part of: the pull request's title. When new commits change the verdict, the comment also says what the verdict was, how many commits arrived since and which pull requests were merged into the branch. Only when someone changes the repository's policy and that changes the verdict on the same commit does Triage post a new comment, which says it supersedes the one above.
Authors do not use the portal. Depending on the repository's actions, they see a comment from Triage on their pull request with the verdict and reasons, review requests, and on GitHub labels such as **Triage: Ready**, **Triage: Not Ready** or **Triage: Needs Human Review** and assignments. On Bitbucket they see the verdict as the **Corbits Triage** build status instead of a label. **Triage: Ready** comes off as soon as Triage sees a new commit and returns once that commit passes, so it never stands on a commit Triage has not checked. While Triage waits for CI, they see **Triage: Waiting for CI** in its place, or no Triage label when the commit has no checks; **Triage: Not Ready** and **Triage: Needs Human Review** stay until the new verdict. Triage keeps one comment and edits it in place as the pull request changes, so an author who pushes a fix sees the fresh verdict in the same comment. Each change Triage asks to split out names what it is not part of: the pull request's title. When new commits change the verdict, the comment also says what the verdict was, how many commits arrived since and which pull requests were merged into the branch. Only when someone changes the repository's policy and that changes the verdict on the same commit does Triage post a new comment, which says it supersedes the one above.

### Pulse

Expand Down
13 changes: 8 additions & 5 deletions docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,12 @@ When the policy depends on CI (CI green is on, a step has a `ci` condition, or
a step approves or merges), Triage waits for CI to finish on the head, or for
its first check to fail, before it triages it. A head with no checks after 2
minutes is taken as having no CI, and none waits longer than an hour; a run by
hand does not wait. Meanwhile the Inbox shows **Waiting for CI**, and the pull
request shows the `Triage: Waiting for CI` label on GitHub (or the `Corbits
Triage` build status in progress on Bitbucket) until the verdict replaces it.
hand does not wait. Meanwhile the Inbox shows **Waiting for CI**. On GitHub a
pull request labelled `Triage: Not Ready` or `Triage: Needs Human Review`
keeps it, so a push that leaves the verdict as it was changes no label; any
other shows `Triage: Waiting for CI` until the verdict replaces it, or no
Triage label while the head has no checks. On Bitbucket the head's `Corbits
Triage` build status is in progress until its verdict.
A head triaged anyway while the only rules left to confirm are the host's own
(CI still running, mergeability not computed yet) gets no verdict yet: it
shows the same marker, nothing is posted or run, and it is triaged again once
Expand All @@ -211,10 +214,10 @@ Their names are stable:

| Label | When |
| --- | --- |
| `Triage: Ready` | The head passes. |
| `Triage: Ready` | The head passes. Taken off as soon as Triage sees a new head, until that head passes. |
| `Triage: Not Ready` | The head fails. |
| `Triage: Needs Human Review` | The head is unsure. |
| `Triage: Waiting for CI` | Triage is waiting for the head's CI before it decides. |
| `Triage: Waiting for CI` | Triage is waiting for the head's CI before it decides, unless the pull request is Not Ready or Needs Human Review or the head has no checks. |
| `rule: <rule id>` | A rule that failed on the head, such as `rule: docs`, from a `{ do: labels, target: { from: rules } }` step. |

A head carries one verdict label at a time, and Triage takes back the ones
Expand Down
5 changes: 4 additions & 1 deletion packages/github-tool/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface MirrorInput {
labels: string[];
/** Labels the caller owns; the mirror removes only these. Optional so requests built before it existed remove nothing. */
owned?: readonly string[];
/** While the pull request carries any of these, its labels are left as they are. */
keep?: readonly string[];
comment?: string;
/** What the comment is written for; without it the comment is written whatever it said before. */
header?: TriageHeader;
Expand Down Expand Up @@ -54,7 +56,8 @@ export async function mirror(gh: GithubFetch, i: MirrorInput, login: AppLogin) {
if (i.comment === "") await deleteTriageComments(gh, repo, number);
const posted = i.comment ? await writeTriageComment(gh, { repo, number, body: i.comment, header: i.header, branch: pr.head.repo?.id === pr.base.repo.id ? pr.head.ref : undefined }, login) : null;
const current: string[] = pr.labels.map((label: { name: string }) => label.name);
await syncLabels(gh, { repo, number, current, add: i.labels, remove: i.owned ?? [] });
const kept = new Set((i.keep ?? []).map(lowercase));
if (!current.some((name) => kept.has(lowercase(name)))) await syncLabels(gh, { repo, number, current, add: i.labels, remove: i.owned ?? [] });
if (i.close) await json(gh, `/repos/${repo}/pulls/${number}`, send("PATCH", { state: "closed" }));
return { commentId: posted?.commentId ?? null, updated: posted?.updated ?? false, closed: i.close, sha: pr.head.sha, ...(posted?.unchanged && { unchanged: posted.unchanged }) };
}
Expand Down
6 changes: 4 additions & 2 deletions packages/github-tool/src/sidecar-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ const MIRROR_INPUT = obj({
number: { type: "integer" },
labels: { type: "array", items: str },
owned: { type: "array", items: str },
keep: { type: "array", items: str, description: "While the pull request carries any of these labels, its labels are left as they are" },
comment: str,
header: {
...obj({
Expand All @@ -150,13 +151,13 @@ const WRITE_DEFINITIONS: ToolDefinition[] = [
{
name: "github_mirror",
description:
"Post (or update) the triage comment on a pull request when comment is given, add labels and remove any owned label not in labels. While notReadyHead is the head, which may not be approved, dismisses the approve Do's standing approvals; while fixedHead is the head, which leaves the author nothing to fix, dismisses the request-changes Do's standing requests. Writes nothing to a closed pull request. Never merges.",
"Post (or update) the triage comment on a pull request when comment is given, add labels and remove any owned label not in labels, unless it carries a keep label. While notReadyHead is the head, which may not be approved, dismisses the approve Do's standing approvals; while fixedHead is the head, which leaves the author nothing to fix, dismisses the request-changes Do's standing requests. Writes nothing to a closed pull request. Never merges.",
inputSchema: MIRROR_INPUT,
},
{
name: "github_mirror_auto",
description:
"Post (or update) the triage comment on a pull request when comment is given, without waiting for a human approval, add labels and remove any owned label not in labels. While notReadyHead is the head, which may not be approved, dismisses the approve Do's standing approvals; while fixedHead is the head, which leaves the author nothing to fix, dismisses the request-changes Do's standing requests. Writes nothing to a closed pull request. Never merges.",
"Post (or update) the triage comment on a pull request when comment is given, without waiting for a human approval, add labels and remove any owned label not in labels, unless it carries a keep label. While notReadyHead is the head, which may not be approved, dismisses the approve Do's standing approvals; while fixedHead is the head, which leaves the author nothing to fix, dismisses the request-changes Do's standing requests. Writes nothing to a closed pull request. Never merges.",
inputSchema: MIRROR_INPUT,
},
{
Expand Down Expand Up @@ -272,6 +273,7 @@ async function dispatch(gh: GithubFetch, call: ToolCallInput, login: AppLogin, t
number: a.number,
labels: a.labels,
owned: a.owned,
keep: a.keep,
comment: a.comment,
header: a.header,
onHead: a.onHead,
Expand Down
4 changes: 2 additions & 2 deletions packages/triage-workflows/src/actions/evaluate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EffectContext } from "@intx/workflow";
import { checkName, ownedLabels, repoSourceOf, TRIAGE_LABELS, type ActionKind, type CheckPack } from "@corbits/triage-contracts";
import { checkName, ownedLabels, repoSourceOf, VERDICT_LABELS, type ActionKind, type CheckPack } from "@corbits/triage-contracts";
import { type } from "arktype";
import { ciRefusal } from "@corbits/github-reads";
import { approvalHeldBy, changesWanted, evaluateActions, heldApprovals, holdDos, packApproves, type SuggestedAction } from "../logic/actions.js";
Expand Down Expand Up @@ -191,7 +191,7 @@ function verdictOf(it: Item, replies: unknown[], partReplies: unknown[]): Verdic
const reasons = [...rendered.reasons, ...(it.pack?.dropped ?? [])];
const labels = it.pack && !rendered.waiting ? [it.pack.labels[rendered.verdict]] : rendered.labels;
const owned = it.pack ? ownedLabels(it.pack.labels) : undefined;
const mark = rendered.waiting && waitingMark(repoSourceOf(it.repoSource), it.facts, automatic, owned ?? TRIAGE_LABELS);
const mark = rendered.waiting && waitingMark(repoSourceOf(it.repoSource), it.facts, automatic, it.pack?.labels ?? VERDICT_LABELS, it.facts.checks !== "none");
const verdict = {
...stamp(it.facts, it), ...rendered, labels, reasons, feedback, automatic, actions, ...(Object.keys(judged).length > 0 && { judged }),
...(it.pack && packApproves(it.pack) && { approvable: held === undefined }), ...(wanted !== undefined && { changesWanted: wanted }),
Expand Down
Loading
Loading