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
4 changes: 2 additions & 2 deletions apps/hub/src/github/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { evaluateGrants } from "@intx/authz";
import { schema, type DB } from "@intx/db";
import { generateId } from "@intx/hub-common";
import type { GrantRule } from "@intx/types/authz";
import { ACTION_KINDS, DO_GRANT_ACTION, doResource, hostKinds, postingKinds, TRIAGE_ROLE, type ActionKind, type RepoSource } from "@corbits/triage-contracts";
import { ACTION_KINDS, DO_GRANT_ACTION, doResource, hostKinds, postingKinds, TRIAGE_ROLE, VERDICT_LABELS, type ActionKind, type RepoSource } from "@corbits/triage-contracts";
import { lockHolder } from "../system-writes/grants.js";
import { allowRole, grantAdminHost, type Tx } from "./admin-github-grant.js";

Expand Down Expand Up @@ -62,7 +62,7 @@ export async function grantWanted(tx: Tx, tenantId: string, repo: string, source
await lockHolder(tx, tenantId, roleId);
// Checked under the role's lock, which every grant writer takes, so a read overtaken meanwhile writes nothing.
if (!stillWanted()) return;
const kinds = hostKinds(source).filter((kind) => wanted.includes(kind) || postingKinds(source).includes(kind));
const kinds = hostKinds(source).filter((kind) => wanted.includes(kind) || postingKinds(source, VERDICT_LABELS).includes(kind));
await deleteKinds(tx, tenantId, roleId, repo, source, ACTION_KINDS.filter((kind) => !kinds.includes(kind)));
for (const kind of kinds) await allowRole(tx, tenantId, roleId, doResource(kind, source, repo), DO_GRANT_ACTION, stamp);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/src/github/policy-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function createPolicyGrants(deps: PolicyGrantsDeps) {
const read = await deps.preview(tenantId, repo, source, text);
if (read.status !== "ok") return [];
// Enabling a repository grants posting, and triage runs on no other.
const waits = policyKinds(read.pack, read.policyWorkflow?.decide ?? [], source).filter((kind) => !postingKinds(source).includes(kind));
const waits = policyKinds(read.pack, read.policyWorkflow?.decide ?? [], source).filter((kind) => !postingKinds(source, read.pack.labels).includes(kind));
return waits.filter((kind) => !read.automatic.includes(kind));
}

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/repo-panel/PolicyRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const WAITS = " (waits for you on Triage)";
* labels, so a label waits there only beside a waiting comment.
*/
function postText(pack: CheckPack, verdict: VerdictWord, kind: ActionKind, allowed: (kind: ActionKind) => boolean): string {
if (kind === "labels") {
if (kind === "labels" && pack.labels) {
const label = `Label “${pack.labels[verdict]}”`;
if (allowed("labels")) return label;
return allowed("comment") ? `${label} (not added: Triage may not add labels here)` : `${label}${WAITS}`;
Expand Down Expand Up @@ -90,7 +90,7 @@ export default function PolicyRules({ pack, source, automatic }: { pack: CheckPa
<div key={verdict} className="then">
<b>{VERDICT_NAMES[verdict]}</b>
<div className="steps">
{postingKinds(source).map((kind) => <span key={kind}>{postText(pack, verdict, kind, allowed)}</span>)}
{postingKinds(source, pack.labels).map((kind) => <span key={kind}>{postText(pack, verdict, kind, allowed)}</span>)}
{pack.actions[verdict].filter((step) => step.do !== "nothing").map((step, at) => <span key={`${verdict}-${at}`}>{doText(step)}{forText(step)}{stepWaits(verdict, step) ? WAITS : ""}</span>)}
</div>
</div>
Expand Down
28 changes: 16 additions & 12 deletions packages/triage-contracts/src/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@ export type PolicyEvent = (typeof POLICY_EVENTS)[number];
export const VERDICTS = ["pass", "fail", "unsure"] as const;
export type VerdictWord = (typeof VERDICTS)[number];

export const VERDICT_LABELS: Record<VerdictWord, string> = { pass: "Triage: Ready", fail: "Triage: Not Ready", unsure: "Triage: Needs Human Review" };
export type VerdictLabels = Record<VerdictWord, string>;

export const VERDICT_LABELS: VerdictLabels = { pass: "Triage: Ready", fail: "Triage: Not Ready", unsure: "Triage: Needs Human Review" };

/** What a head's verdict still waits for. */
export type Awaits = "ci" | "judge" | "code-review";
Expand Down Expand Up @@ -442,8 +444,8 @@ export type CheckPack = {
checks: Partial<Record<CatalogId, CatalogCheck>>;
custom: CustomCheck[];
actions: PolicyActions;
/** So tools watching GitHub can react to it. */
labels: Record<VerdictWord, string>;
/** So tools watching GitHub can react to it; absent, Triage writes no labels. */
labels?: VerdictLabels;
/** The groups the policy file defines, in its order, which steps name as `{ group }`. */
groups?: Record<string, FileGroup>;
/** What reading the stored policy left out because it no longer runs; each verdict says so. Never read back. */
Expand Down Expand Up @@ -583,7 +585,7 @@ export const checkPackSchema = type({
"checks?": CatalogChecksSchema,
"custom?": CustomCheckSchema.array().atMostLength(CUSTOM_CHECK_CAP),
"actions?": PolicyActionsSchema,
"labels?": { "+": "reject", "pass?": Label, "fail?": Label, "unsure?": Label },
"labels?": type("true").or({ "+": "reject", "pass?": Label, "fail?": Label, "unsure?": Label }),
"groups?": FileGroupsSchema,
});

Expand Down Expand Up @@ -1158,8 +1160,8 @@ function parseGroups(raw: unknown): Record<string, FileGroup> | undefined {
return groups;
}

function parseLabels(raw: unknown): Record<VerdictWord, string> {
if (raw === undefined) return { ...VERDICT_LABELS };
function parseLabels(raw: unknown): VerdictLabels | undefined {
if (raw === undefined || raw === true) return { ...VERDICT_LABELS };
const row = record(raw, "labels");
const extra = Object.keys(row).filter((key) => !(VERDICTS as readonly string[]).includes(key));
if (extra.length) throw new Error(`labels must set only pass, fail or unsure, not ${extra.join(", ")}.`);
Expand Down Expand Up @@ -1295,7 +1297,7 @@ export function readCheckPack(raw: unknown, expectedRepo?: string, refuse = fals
const actions = parseActions(body.actions, dropped, refuse);
const labels = parseLabels(body.labels);
const groups = parseGroups(body.groups);
return withDropped(refuseTriageLabels({ kind: CHECK_PACK_KIND, schemaVersion: CHECK_PACK_SCHEMA_VERSION, repo, checks, custom, actions, labels, ...(groups && { groups }) }), dropped);
return withDropped(refuseTriageLabels({ kind: CHECK_PACK_KIND, schemaVersion: CHECK_PACK_SCHEMA_VERSION, repo, checks, custom, actions, ...(labels && { labels }), ...(groups && { groups }) }), dropped);
}

export type SharedPolicy = Omit<CheckPack, "repo">;
Expand All @@ -1304,7 +1306,8 @@ export type PolicyOverride = Pick<CheckPack, "repo" | "custom"> & {
extends: string;
checks: Partial<Record<CatalogId, Partial<CatalogCheck>>>;
actions: Partial<PolicyActions>;
labels: Partial<Record<VerdictWord, string>>;
/** `true` turns the verdict labels on with their default names. */
labels: Partial<VerdictLabels> | true;
};

export function extendsOf(raw: unknown): string | undefined {
Expand All @@ -1323,7 +1326,7 @@ export function readSharedPolicy(raw: unknown): SharedPolicy {
const custom = parseCustom(body.custom);
const actions = parseActions(body.actions, dropped, false);
const labels = parseLabels(body.labels);
return withDropped({ kind: CHECK_PACK_KIND, schemaVersion: CHECK_PACK_SCHEMA_VERSION, checks, custom, actions, labels }, dropped);
return withDropped({ kind: CHECK_PACK_KIND, schemaVersion: CHECK_PACK_SCHEMA_VERSION, checks, custom, actions, ...(labels && { labels }) }, dropped);
}

/** Built-in rule rows as a repository sets them: `enabled` only where the row names it, so a limit alone keeps the shared switch. */
Expand All @@ -1348,8 +1351,9 @@ function parseOverrideActions(row: Partial<Record<"when" | VerdictWord, unknown>
return actions;
}

function parseOverrideLabels(row: Partial<Record<VerdictWord, unknown>> | undefined): PolicyOverride["labels"] {
const labels: PolicyOverride["labels"] = {};
function parseOverrideLabels(row: true | Partial<Record<VerdictWord, unknown>> | undefined): PolicyOverride["labels"] {
if (row === true) return true;
const labels: Partial<VerdictLabels> = {};
for (const verdict of VERDICTS) {
if (row?.[verdict] !== undefined) labels[verdict] = text(row[verdict], `labels ${verdict}`, LABEL_MAX);
}
Expand Down Expand Up @@ -1436,7 +1440,7 @@ function levelLabelRule(label: string): LevelLabelRule | undefined {
* step naming a level label would flip it on and off across runs. Sensitive paths and risk add their own level labels.
*/
export function refuseTriageLabels(pack: CheckPack): CheckPack {
const verdictLabels = Object.values(pack.labels).map((label) => label.toLowerCase());
const verdictLabels = Object.values(pack.labels ?? {}).map((label) => label.toLowerCase());
for (const [id, label] of labelRules(pack)) {
const lower = label.toLowerCase();
const level = levelLabelRule(lower);
Expand Down
10 changes: 5 additions & 5 deletions packages/triage-contracts/src/do.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type } from "arktype";
import { ACTION_KINDS, VERDICTS, type ActionKind, type CheckPack, type VerdictWord } from "./check-pack.js";
import { ACTION_KINDS, VERDICTS, type ActionKind, type CheckPack, type VerdictLabels, type VerdictWord } from "./check-pack.js";
import { kindsOf, type DecideRule } from "./policy-workflow.js";
import type { RepoSource } from "./repo-source.js";

Expand Down Expand Up @@ -41,9 +41,9 @@ export function alwaysHeld(list: VerdictWord, kind: ActionKind): boolean {
return (list === "unsure" && kind === "close") || (list !== "pass" && kind === "merge");
}

/** What Triage posts with every verdict: a comment, and a label where the host has labels. */
export function postingKinds(source: RepoSource): ActionKind[] {
return hostKinds(source).filter((kind) => kind === "comment" || kind === "labels");
/** What Triage posts with every verdict: a comment, and a label where the host has labels and the policy turns them on. */
export function postingKinds(source: RepoSource, labels: VerdictLabels | undefined): ActionKind[] {
return hostKinds(source).filter((kind) => kind === "comment" || (kind === "labels" && labels !== undefined));
}

/**
Expand All @@ -52,7 +52,7 @@ export function postingKinds(source: RepoSource): ActionKind[] {
*/
export function policyKinds(pack: CheckPack, decide: readonly DecideRule[], source: RepoSource): ActionKind[] {
const steps = VERDICTS.flatMap((verdict) => pack.actions[verdict].filter((step) => step.do === "nothing" || !alwaysHeld(verdict, step.do)).map((step) => step.do));
const used = new Set<string>([...postingKinds(source), ...decide.flatMap((rule) => kindsOf(rule.do)), ...steps]);
const used = new Set<string>([...postingKinds(source, pack.labels), ...decide.flatMap((rule) => kindsOf(rule.do)), ...steps]);
return hostKinds(source).filter((kind) => used.has(kind));
}

Expand Down
5 changes: 3 additions & 2 deletions packages/triage-contracts/src/policy-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,9 @@ function fileActions(pack: CheckPack): Record<string, unknown> | undefined {
return Object.keys(actions).length ? actions : undefined;
}

function fileLabels(pack: CheckPack): Record<string, string> | undefined {
const changed = VERDICTS.flatMap((verdict) => (pack.labels[verdict] === VERDICT_LABELS[verdict] ? [] : [[verdict, pack.labels[verdict]]]));
function fileLabels({ labels }: CheckPack): Record<string, string> | undefined {
if (!labels) return undefined;
const changed = VERDICTS.flatMap((verdict) => (labels[verdict] === VERDICT_LABELS[verdict] ? [] : [[verdict, labels[verdict]]]));
return changed.length ? Object.fromEntries(changed) : undefined;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/triage-contracts/src/policy-file-problems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const EXPECTED: ReadonlyArray<readonly [Pattern, string | typeof MAPPING, "exact
[["actions"], MAPPING],
[["actions", "on"], `a list of ${words(POLICY_EVENTS)}`],
[["actions", VERDICT], "a list of steps", "exact"],
[["labels"], MAPPING],
[["labels"], "true, or a mapping of pass, fail and unsure"],
[["labels", VERDICT], LABEL_TEXT],
[["groups"], "a mapping of group names to who is in them"],
[["groups", NAME], MAPPING],
Expand Down
2 changes: 1 addition & 1 deletion packages/triage-contracts/src/policy-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const policyRulesSchema = type({
"onFail?": OnFailWord,
rules: policyRuleSchema.array(),
"actions?": { "+": "reject", "on?": type.enumerated(...POLICY_EVENTS).array().atLeastLength(1), "pass?": stepListSchema, "fail?": stepListSchema, "unsure?": stepListSchema },
"labels?": { "+": "reject", "pass?": Label, "fail?": Label, "unsure?": Label },
"labels?": type("true").or({ "+": "reject", "pass?": Label, "fail?": Label, "unsure?": Label }),
...WORKFLOW_PROPS,
});

Expand Down
6 changes: 3 additions & 3 deletions packages/triage-contracts/src/presets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FAILED_LABEL, VERDICT_LABELS, type VerdictWord } from "./check-pack.js";
import { FAILED_LABEL, VERDICT_LABELS, type VerdictLabels } from "./check-pack.js";
import type { Priority, WorkflowState } from "./types.js";

const OWNERS: Record<WorkflowState, string> = {
Expand Down Expand Up @@ -30,8 +30,8 @@ const RETIRED_LABELS = [
export const TRIAGE_LABELS: readonly string[] = [...Object.values(VERDICT_LABELS), FAILED_LABEL, ...RETIRED_LABELS];

/** The mirror takes back these when they no longer apply. */
export function ownedLabels(labels: Record<VerdictWord, string>): string[] {
return [...new Set([...TRIAGE_LABELS, ...Object.values(labels)])];
export function ownedLabels(labels: VerdictLabels | undefined): string[] {
return [...new Set([...TRIAGE_LABELS, ...Object.values(labels ?? {})])];
}

export interface Rendered {
Expand Down
15 changes: 10 additions & 5 deletions packages/triage-contracts/src/resolve-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import {
readPolicyOverride,
readSharedPolicy,
refuseTriageLabels,
VERDICT_LABELS,
type CatalogCheck,
type CatalogId,
type CheckPack,
type PolicyOverride,
type SharedPolicy,
type VerdictLabels,
} from "./check-pack.js";

/** `sha` is the file's blob on the default branch. */
Expand Down Expand Up @@ -61,14 +63,17 @@ export function resolvePolicy(shared: SharedPolicy, repo: PolicyOverride): Check
fail: repo.actions.fail ?? shared.actions.fail,
unsure: repo.actions.unsure ?? shared.actions.unsure,
},
labels: {
pass: repo.labels.pass ?? shared.labels.pass,
fail: repo.labels.fail ?? shared.labels.fail,
unsure: repo.labels.unsure ?? shared.labels.unsure,
},
...resolvedLabels(repo.labels, shared.labels),
});
}

/** The repository's own `labels` key wins, each name it leaves out taken from the shared policy or the default; without one the shared policy decides. */
function resolvedLabels(repo: PolicyOverride["labels"], shared: VerdictLabels | undefined): { labels?: VerdictLabels } {
if (repo === true) return { labels: { ...VERDICT_LABELS } };
if (Object.keys(repo).length === 0) return shared ? { labels: shared } : {};
return { labels: { ...VERDICT_LABELS, ...shared, ...repo } };
}

/** A policy file's pack over the policy file of the repository its `extends` names, which must extend nothing itself. */
export function extendedFilePack(repo: string, content: unknown, shared: { repo: string; policy: Record<string, unknown> }): CheckPack {
if (shared.policy.extends !== undefined) throw new Error(`${shared.repo}'s policy extends another, so no policy can extend it.`);
Expand Down
24 changes: 14 additions & 10 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, FAILED_LABEL, hostKinds, marksWaiting, ownedLabels, repoSourceOf, UNREAD_STATUS, VERDICT_LABELS, type ActionKind, type CheckPack, type RepoSource, type Step } from "@corbits/triage-contracts";
import { checkName, FAILED_LABEL, hostKinds, marksWaiting, ownedLabels, repoSourceOf, UNREAD_STATUS, VERDICT_LABELS, type ActionKind, type CheckPack, type RepoSource, type Step, type VerdictLabels } from "@corbits/triage-contracts";
import { type } from "arktype";
import { approvalHeldBy, changesWanted, heldApprovals, holdDos, packApproves, stepActions, type ActionInput, type SuggestedAction } from "../logic/actions.js";
import type { CheckResult, DeterministicResult, PrFacts } from "../logic/checks.js";
Expand Down Expand Up @@ -155,19 +155,25 @@ export function withoutDos(feedback: string, actions: SuggestedAction[]): string
* verdict label on GitHub, an error status on Bitbucket Data Center. None without a known head, so a newer head is
* never touched, nor where triage does not write the verdict.
*/
function degradedMark({ repo, number, headSha }: PrFacts, source: RepoSource, automatic: ActionKind[], owned: readonly string[], appLogin: string | undefined): WaitingMark | undefined {
function degradedMark({ repo, number, headSha }: PrFacts, source: RepoSource, automatic: ActionKind[], labels: VerdictLabels | undefined, owned: readonly string[], appLogin: string | undefined): WaitingMark | undefined {
if (headSha === "" || !marksWaiting(source, automatic)) return undefined;
const pull = { repo, number, onHead: headSha, ...(appLogin && { appLogin }) };
const args = source === "bitbucket-datacenter"
? { ...pull, labels: [], owned: [], status: UNREAD_STATUS, statusHead: headSha }
: { ...pull, labels: [FAILED_LABEL], owned };
: { ...pull, labels: labels ? [FAILED_LABEL] : [], owned };
return { effectId: `degraded:${repo.replace("/", "--")}:${number}:${headSha}`, repoSource: source, args };
}

/** Without a policy Triage labels with the default names. */
function verdictLabelsOf(it: Item): VerdictLabels | undefined {
return it.pack ? it.pack.labels : VERDICT_LABELS;
}

function degradedOf(it: Item, reason: string): Verdict {
const verdict = { ...stamp(it, null), ...degradedVerdict(reason), automatic: it.automatic, actions: [] };
const labels = verdictLabelsOf(it);
const verdict = { ...stamp(it, null), ...degradedVerdict(reason, labels), automatic: it.automatic, actions: [] };
const request = toMirrorRequest(verdict, it.pack && ownedLabels(it.pack.labels));
const mark = degradedMark(it.facts, repoSourceOf(it.repoSource), verdict.automatic, request.owned, it.appLogin);
const mark = degradedMark(it.facts, repoSourceOf(it.repoSource), verdict.automatic, labels, request.owned, it.appLogin);
return { ...verdict, request, ...(mark && { mark }) };
}

Expand Down Expand Up @@ -205,14 +211,12 @@ function verdictFrom(it: Item, rendered: RenderOutput): Verdict {
const shown = it.blocksOn ? rendered.feedback : withDos(rendered.feedback, actions);
// What reading the policy left out is said on every verdict, so the policy can be fixed.
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 source = repoSourceOf(it.repoSource);
const labelNames = it.pack?.labels ?? VERDICT_LABELS;
const closed = it.facts.state === "closed";
const mark = closed ? closedMark(source, it.facts, automatic, labelNames, it.appLogin) : rendered.waiting && waitingMark(source, it.facts, ["ci"], automatic, it.appLogin);
const mark = closed ? closedMark(source, it.facts, automatic, verdictLabelsOf(it), it.appLogin) : rendered.waiting && waitingMark(source, it.facts, ["ci"], automatic, it.appLogin);
const verdict = {
...stamp(it, it.facts.headSha), ...rendered, labels, reasons, feedback: shown, automatic, actions,
...stamp(it, it.facts.headSha), ...rendered, reasons, feedback: shown, automatic, actions,
...(it.pack && packApproves(it.pack) && { approvable: held === undefined }), ...(wanted !== undefined && { changesWanted: wanted }),
...(mark && { mark }), ...(closed && { closed: true as const }),
};
Expand Down Expand Up @@ -261,7 +265,7 @@ function verdictOf(it: Item, replies: unknown[], partReplies: unknown[]): Verdic
const answers = { decisions, malformed: false };
const answered = choiceOver(verdictAnswer, VERDICT_CHOICES);
const rendered = renderVerdict({
author: it.facts.author, repo: it.facts.repo, repoSource: repoSourceOf(it.repoSource), headSha: it.facts.headSha, det: it.det, answers, plan, focused, stack: it.facts.stack, judgeError,
author: it.facts.author, repo: it.facts.repo, repoSource: repoSourceOf(it.repoSource), headSha: it.facts.headSha, det: it.det, labels: verdictLabelsOf(it), answers, plan, focused, stack: it.facts.stack, judgeError,
judgeLimitExceeded: it.judgeLimitExceeded, judgeSkipped: it.judgeSkipped, reviewers: it.facts.reviewers, choice: answered,
...(choice && "judgeError" in choice && { choiceRefused: choice.judgeError }), ...(hostAwaited(it) !== undefined && { hostAwaited: hostAwaited(it) }),
});
Expand Down
Loading
Loading