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
76 changes: 46 additions & 30 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,25 +273,40 @@ any other tenant or an unknown sender.
the claim: the next manager's take finds the run gone and claims.
A run left deployed with no sidecar for two minutes is gone too, and its
cancellation is requested so it does not start after the claim has moved on.
When the rules need the judge and `release.json` publishes one for the host,
a pass deploys it under `workflow:*` `create` and `read` on exactly the
judge's asset, which the definition declares when its module loads with that
release, mails it the rules output until the judge's one-hour deadline, and
posts nothing. The pass that has its answers runs the verdict. A judge the
release does not publish posts nothing. One that could not be mailed,
reported a failure or ran past its deadline goes on to the verdict and is
not deployed again. When the policy turns code review on, the verdict's
rules let it past, the hub read the head's review request and the release
publishes a code review, a pass deploys it the same way (without one it
declares no code-review asset, and the verdict goes up alone), mails it the
request until the review's one-hour deadline, and still posts nothing while
that review is out. The pass that collects its report mirrors the verdict,
then posts the review through the host's `send_code_review` tool, so they
go up together. A review that could not be mailed, reported a failure or
ran past its deadline leaves the verdict to go up alone. Each reviewer the
manager records names the manager run that deployed it (`deployedBy`). Done
stops each judge or code review this run deployed and no longer waits for,
one on an older head or past its deadline, through the same stop-run route;
When the rules need the judge, the host's own checks have finished (or the
hub waits for them no longer) and `release.json` publishes a judge for the
host, a pass deploys it under `workflow:*` `create` and `read` on exactly
the judge's asset, which the definition declares when its module loads with
that release, mails it the rules output until the judge's one-hour deadline,
and posts nothing. While those checks run, the judge is held and the pass
marks the head waiting, as a pass of deterministic rules does. The pass that
collects the judge's report posts and executes its verdict as the judge made
it, once that verdict names the pass's pull request, head and host; facts
and rules still run, and when they no longer need the judge (a failed hard
rule, a draft or a closed pull request) the pass's own verdict goes up
instead. A decided verdict, one that does not wait and that nothing on the
host can still change, stays on the judge's reviewer on the claim for its
head, so a later pass on that head posts it again, whatever else changed,
and deploys no other judge; a new head gets its own. A verdict the host's
checks could still change, a degraded one or one over 256 KiB still goes up
on the pass that collects it, and a failure or a missed deadline leaves the
machine verdict to go up alone; none of them is kept, so the next event
deploys the judge again. A judge the release does not publish posts nothing.
When the policy turns code review on, the verdict's rules let it past, the
hub read the head's review request and the release publishes a code review,
a pass deploys it the same way (without one it declares no code-review
asset, and the verdict goes up alone), mails it the request until the
review's one-hour deadline, and still posts nothing while that review is
out. The pass that collects its report mirrors the verdict, then posts the
review through the host's `send_code_review` tool, so they go up together. A
review stays on its reviewer for its head like a decided verdict, so a later
pass on that head whose rules still let code review past posts it with the
verdict and deploys no other; a review that could not be mailed, reported a
failure or ran past its deadline leaves the verdict to go up alone and is
deployed again on the next event. Each reviewer the manager records names
the manager run that deployed it (`deployedBy`). Done stops each judge or
code review this run deployed and no longer waits for, one on an older head
or past its deadline, through the same stop-run route;
one an earlier manager deployed runs out its own deadline. Stock grants the
deployer `read` on a run it deployed, and the vendored Interchange carries
`manage` instead (`vendor/interchange/VENDORED.md`).
Expand All @@ -306,16 +321,17 @@ any other tenant or an unknown sender.
`triage-manager:result` and `manage` on `workflow-run:self`, sourced from
its creator: it can report a result and stop itself, and reach no other
manager route.
- `triage-judge-github` and `triage-judge-bitbucket-datacenter`
(`triageJudge` in `@corbits/triage-judge`): the judge a manager deploys. It
takes mail only from that manager. It runs the model checks the machine
rules produced, then focused's follow-up questions, then evaluate. Stock
runs a step whose dependencies failed, so however the judge ended it records
a `StageResult` of kind `judge` through the reviewer-result route (a failure
as `failed`), including the answers a later pass of the same head replays,
mails the manager a wake-up and stops. It posts nothing to the pull request.
Its definition declares only `create` on `triage-manager:result` and
`manage` on `workflow-run:self`, sourced from its creator.
- `triage-judge-github` and `triage-judge-bitbucket-datacenter` (`triageJudge`
in `@corbits/triage-judge`): the judge a manager deploys. It takes mail only
from that manager. It runs the model checks the machine rules produced, then
focused's follow-up questions, then evaluate. Stock runs a step whose
dependencies failed, so however the judge ended it records a `StageResult`
of kind `judge` through the reviewer-result route: the whole verdict
evaluate made, its comment, labels, Dos and rule results, or a failure as
`failed`. It mails the manager a wake-up and stops. It posts nothing to the
pull request. Its definition declares only `create` on
`triage-manager:result` and `manage` on `workflow-run:self`, sourced from
its creator.
- A run sends mail only through an agent step's transport, so the listener,
the manager, the judge and the code review mail through one agent whose
director makes no model call: it runs its one tool, which waits for a
Expand Down Expand Up @@ -838,7 +854,7 @@ read it, and passes the rest of the request to stock.
own run id, and the manager matches results to its reviewers by run id.
Its body is a `StageResult` (`@corbits/triage-contracts`): the reviewer's
kind (`code-review` or `judge`), the head it reviewed and its result, a
code review's output or a judge's verdict detail, or `failed` with why it
code review's output or a judge's whole verdict, or `failed` with why it
has none.
- In a workspace with a host's listener live, pull request, waiting,
held-Do approval and backlog mail goes to it instead of a pr-triage worker,
Expand Down
2 changes: 0 additions & 2 deletions packages/triage-contracts/src/hub-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ export const VerdictDetail = type({
"automatic?": "string[]",
/** Why the run's mirror posted nothing on the host: `stale`, `waiting`, `no_request` or `no_grants`. */
"notPosted?": "string",
/** Answers a later pass of the same head replays, so the judge is not asked again. */
"judged?": "Record<string, string>",
"facts?": { "+": "delete", "title?": "string", "author?": "string", "draft?": "boolean", "mergeable?": "boolean | null", "checks?": "string", "reviewAnswered?": "boolean" },
});
export type VerdictDetail = typeof VerdictDetail.infer;
Expand Down
7 changes: 4 additions & 3 deletions packages/triage-contracts/src/stage-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ import { type } from "arktype";
import { StageResult } from "./stage-result.js";

const REVIEW = { summary: "One bug.", comments: [{ path: "src/a.ts", start: 3, end: 4, title: "Off by one", body: "The loop skips the last item." }], budgetExhausted: false };
const VERDICT = { verdict: "pass", reasons: ["Every rule passed."] };
const VERDICT = { repo: "acme/widgets", number: 8, headSha: "h1", verdict: "pass", checks: [], request: { repo: "acme/widgets", number: 8, comment: "Ready." }, automatic: ["labels"], actions: [] };

test("a stage result is read by its kind, and one whose result is not that kind's output is refused", () => {
expect(StageResult({ kind: "code-review", head: "h1", result: REVIEW })).toEqual({ kind: "code-review", head: "h1", result: REVIEW });
expect(StageResult({ kind: "judge", head: "h1", result: VERDICT })).toEqual({ kind: "judge", head: "h1", result: VERDICT });
expect(StageResult({ kind: "judge", head: "h1", result: { ...VERDICT, judged: { k: "v" }, extra: true } })).toEqual({ kind: "judge", head: "h1", result: { ...VERDICT, judged: { k: "v" } } });
expect(StageResult({ kind: "judge", head: "h1", result: { ...VERDICT, reasons: ["Every rule passed."], mirror: true } })).toEqual({ kind: "judge", head: "h1", result: { ...VERDICT, reasons: ["Every rule passed."], mirror: true } });
expect(StageResult({ kind: "code-review", head: "h1", failed: "the review did not finish" })).toEqual({ kind: "code-review", head: "h1", failed: "the review did not finish" });
for (const malformed of [
{ kind: "code-review", head: "h1", result: VERDICT },
{ kind: "judge", head: "h1", result: { verdict: "maybe" } },
{ kind: "judge", head: "h1", result: { ...VERDICT, verdict: "maybe" } },
{ kind: "judge", head: "h1", result: { verdict: "pass", reasons: ["Every rule passed."] } },
{ kind: "code-review", result: REVIEW },
{ kind: "lint", head: "h1", result: {} },
]) {
Expand Down
22 changes: 20 additions & 2 deletions packages/triage-contracts/src/stage-result.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type } from "arktype";
import { VERDICTS } from "./check-pack.js";
import { CodeReviewOutput } from "./code-review.js";
import { VerdictDetail } from "./hub-responses.js";
import { REPO_SOURCES } from "./repo-source.js";

/** The grant on a pull request's manager claim and events that the hub's manager routes require. */
export const MANAGER_STATE_RESOURCE = "triage-manager:state";
Expand All @@ -12,11 +13,28 @@ export const MANAGER_RESULT_ACTION = "create";

const Reported = type({ head: "string > 0" });

/**
* A verdict whole, as pr-triage's evaluate made it, so the manager posts and executes it as the judge reported it: the
* fields its mirror and execute read are checked, and every other is kept.
*/
export const JudgeVerdict = type({
repo: "string",
number: "number",
headSha: "string | null",
"repoSource?": type.enumerated(...REPO_SOURCES),
verdict: type.enumerated(...VERDICTS),
checks: "object[]",
request: { repo: "string", number: "number" },
automatic: "string[]",
actions: "object[]",
});
export type JudgeVerdict = typeof JudgeVerdict.infer;

/**
* What a reviewer reports to its pull request's manager: the head it reviewed and its result, or why it has none; the
* hub knows the reporting run from its run token.
*/
export const StageResult = Reported.and({ kind: "'code-review'", result: CodeReviewOutput })
.or(Reported.and({ kind: "'judge'", result: VerdictDetail }))
.or(Reported.and({ kind: "'judge'", result: JudgeVerdict }))
.or(Reported.and({ kind: "'code-review' | 'judge'", failed: "string > 0", "result?": "never" }));
export type StageResult = typeof StageResult.infer;
4 changes: 2 additions & 2 deletions packages/triage-judge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

The judge a pull request's manager deploys (`triageJudge`, `triage-judge-<host>`). The manager mails it the rules output
for one head. It runs those chunks through System One, then focused's follow-up questions, then evaluate, and reports
the verdict — including the answers a later pass can replay — through the reviewer-result route. It mails the manager
a wake-up and stops, whether or not the judge finished. It posts nothing to the pull request.
the whole verdict, which the manager posts as it is, through the reviewer-result route. It mails the manager a wake-up
and stops, whether or not the judge finished. It posts nothing to the pull request.

Only the manager that deployed it may mail it. Its definition declares `create` on `triage-manager:result` and `manage`
on its own run.
Expand Down
4 changes: 2 additions & 2 deletions packages/triage-judge/src/actions/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const MAIL = {
headers: { from: "run_manager@acme.test", to: ["run_judge@acme.test"] },
parts: [{ contentType: "text/plain", text: JSON.stringify({ kind: "judge", tenantId: "tnt_acme", number: 8, checkout: CHECKOUT, rules: RULES, reply: REPLY }) }],
};
const VERDICT = { verdict: "pass", reasons: ["Every rule passed."], judged: { k: "v" }, extra: true };
const VERDICT = { repo: "acme/widgets", number: 8, headSha: "h1", verdict: "pass", reasons: ["Every rule passed."], checks: [], request: { repo: "acme/widgets", number: 8, comment: "Ready." }, automatic: ["labels"], actions: [] };

/** Each effect runs once against a hub that records its calls and records the result. */
function hubContext() {
Expand All @@ -32,7 +32,7 @@ test("a manager's judge hands over the rules, reports the verdict through the hu
expect(await stop(MAIL, ctx, signal)).toEqual({ stopped: true });
const reported = { source: "github", repo: "acme/widgets", number: 8, kind: "judge", head: "h1" };
expect(calls).toEqual([
{ method: "POST", path: "/api/tenants/tnt_acme/manager/reviewer-result", body: { ...reported, result: { verdict: "pass", reasons: ["Every rule passed."], judged: { k: "v" } } } },
{ method: "POST", path: "/api/tenants/tnt_acme/manager/reviewer-result", body: { ...reported, result: VERDICT } },
{ method: "POST", path: "/api/tenants/tnt_acme/manager/reviewer-result", body: { ...reported, failed: "the judge did not finish" } },
{ method: "DELETE", path: "/api/tenants/tnt_acme/workflows/runs/run_judge" },
]);
Expand Down
6 changes: 3 additions & 3 deletions packages/triage-judge/src/actions/report.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { EffectContext } from "@intx/workflow";
import { type } from "arktype";
import { REPO_SOURCES, VerdictDetail } from "@corbits/triage-contracts";
import { JudgeVerdict, REPO_SOURCES } from "@corbits/triage-contracts";
import type { MailInput } from "@corbits/triage-manager/mail";
import { answered, callHub, endRun, mailOf, tenantPath } from "@corbits/triage-manager/actions/hub";

Expand Down Expand Up @@ -34,13 +34,13 @@ const NOT_FINISHED = "the judge did not finish";

/**
* Reports the verdict to the pull request's manager as a `StageResult` through the hub, or that the judge did not
* finish; the reply then wakes the manager. The verdict keeps the answers a later pass replays. Posts nothing.
* finish; the reply then wakes the manager. The verdict goes whole, so the manager posts it as it was made. Posts nothing.
*/
export async function report(input: unknown, ctx: EffectContext, _signal: AbortSignal): Promise<{ recorded: boolean; mail: MailInput }> {
const parsed = ReportInput(input);
if (parsed instanceof type.errors) throw new Error(`report: ${parsed.summary}`);
const { tenantId, number, checkout, reply } = judgeMailOf(parsed.payload).mail;
const verdict = VerdictDetail(parsed.evaluate?.output);
const verdict = JudgeVerdict(parsed.evaluate?.output);
const outcome = parsed.evaluate?.output === undefined || verdict instanceof type.errors ? { failed: NOT_FINISHED } : { result: verdict };
const result = { source: checkout.source, repo: checkout.repo, number, kind: "judge" as const, head: checkout.sha, ...outcome };
const { recorded } = answered(await callHub(ctx, "report", tenantPath(tenantId, "/manager/reviewer-result"), "POST", result), "report", Recorded);
Expand Down
6 changes: 2 additions & 4 deletions packages/triage-judge/src/triage-judge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,15 @@ async function runJudge(rulesOutput: { chunks: unknown[] }) {
return { terminalStatus, calls, mailed };
}

test("the judge runs the chunks the manager mailed, reports the verdict and stops, posting nothing", async () => {
test("the judge runs the chunks the manager mailed, reports the whole verdict and stops, posting nothing", async () => {
const rulesOutput = await ruled();
expect(rulesOutput.chunks.length).toBeGreaterThan(0);
const { terminalStatus, calls, mailed } = await runJudge(rulesOutput);
expect(terminalStatus).toBe("completed");
const posted = calls.find((call) => call.method === "POST");
expect(posted).toMatchObject({ path: `/api/tenants/${TENANT}/manager/reviewer-result`, body: { kind: "judge", head: SHA, source: "github", repo: "acme/widgets", number: 8 } });
if (posted === undefined) throw new Error("the judge posted nothing");
const result = (posted.body as { result?: { verdict?: string; judged?: Record<string, string> } }).result;
expect(result?.verdict).toBe("pass");
expect(Object.keys(result?.judged ?? {}).length).toBeGreaterThan(0);
expect((posted.body as { result?: unknown }).result).toMatchObject({ headSha: SHA, verdict: "pass", request: { header: { head: SHA, verdict: "pass" } }, automatic: [], actions: [] });
expect(calls.some((call) => call.path.includes("github.com"))).toBe(false);
expect(mailed).toHaveLength(1);
expect(calls.at(-1)).toEqual({ method: "DELETE", path: `/api/tenants/${TENANT}/workflows/runs/${RUN}` });
Expand Down
17 changes: 10 additions & 7 deletions packages/triage-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ A workspace's listener and a pull request's manager.
steps run on the newest mail the hub recorded for the head. A policy of only deterministic rules posts on that pass.
One whose rules need the judge, or that asks for code review, posts once, after each of those has reported or given
up. The next pass runs at once while an event is pending, or every 30 seconds while it waits for a reviewer. With a
published judge, a pass deploys [`@corbits/triage-judge`](../triage-judge) and mails it the rules output; the pass
that has its answers runs the verdict. With code review, a pass then deploys the review and mails it the head's
request; the pass that collects its report mirrors the verdict and posts the review together. A reviewer that could
not be mailed, failed or ran past its one-hour deadline leaves the verdict to go up alone; a judge the release does
not publish posts nothing. `done` stops a reviewer this run deployed that is on an older head or past its deadline.
The run's last step is its only end: it stops the run once `done` ends the pull request's claim, take is refused
because another run holds it, or the passes fail or run out.
published judge, a pass deploys [`@corbits/triage-judge`](../triage-judge) once the host's checks have finished and
mails it the rules output; while they run the head waits. The pass that collects its report posts the judge's
verdict as the judge made it while the rules still need the judge, and so does every later pass on that head once
the verdict is decided, deploying no other judge. With code review, a pass then deploys the review and mails it the
head's request; the pass that collects its report mirrors the verdict and posts the review together, and a later
pass on that head posts the stored review again without deploying another. A reviewer that could not be mailed,
failed or ran past its one-hour deadline leaves the verdict to go up alone and is deployed again on the next event;
a judge the release does not publish posts nothing. `done` stops a reviewer this run deployed that is on an older
head or past its deadline. The run's last step is its only end: it stops the run once `done` ends the pull
request's claim, take is refused because another run holds it, or the passes fail or run out.

What each deploys next, a published workflow's source asset at one commit and the offerings it runs on (`Release`), is
read per host from `src/release.json`, which the portal writes into each published tree and the bundle keeps external,
Expand Down
4 changes: 2 additions & 2 deletions packages/triage-manager/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { evaluate, execute, facts, mirror, rules, waiting } from "@corbits/triage-workflows/actions/index";
export { execute, facts, mirror, rules, waiting } from "@corbits/triage-workflows/actions/index";
export { executeApprovals } from "./approvals.js";
export { forward } from "./listener.js";
export { deployJudge, judgeMailed, judgeScope, recall } from "./judge.js";
export { deployJudge, judgeMailed, judgeScope, verdict } from "./judge.js";
export { done, stop, take } from "./manager.js";
export { deployReview, postReview, reviewMailed, reviewScope } from "./review.js";
Loading
Loading