@intentius/chant 0.124.2 → 0.124.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/override-approve.d.ts +3 -1
- package/dist/cli/handlers/override-approve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +1 -33
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-pending.d.ts +41 -0
- package/dist/lifecycle/gate-pending.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/decide.d.ts +2 -0
- package/dist/op/activities/decide.d.ts.map +1 -1
- package/dist/op/gate-point-run.d.ts +5 -77
- package/dist/op/gate-point-run.d.ts.map +1 -1
- package/dist/op/gate-point.d.ts +18 -0
- package/dist/op/gate-point.d.ts.map +1 -1
- package/dist/op/gate.d.ts +8 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +37 -6
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +38 -4
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/steward-points.d.ts +7 -0
- package/dist/op/steward-points.d.ts.map +1 -1
- package/dist/override-gate.d.ts +8 -2
- package/dist/override-gate.d.ts.map +1 -1
- package/dist/point-decision.d.ts +134 -0
- package/dist/point-decision.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +37 -2
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +3 -1
- package/dist/workspace/decide.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/override-approve.test.ts +16 -0
- package/src/cli/handlers/override-approve.ts +6 -2
- package/src/cli/main.test.ts +3 -0
- package/src/cli/main.ts +9 -2
- package/src/cli/registry.ts +2 -0
- package/src/lifecycle/gate-ledger.ts +3 -53
- package/src/lifecycle/gate-pending.ts +62 -0
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/decide.ts +3 -0
- package/src/op/gate-point-ci.e2e.test.ts +171 -0
- package/src/op/gate-point-mcp.e2e.test.ts +2 -0
- package/src/op/gate-point-run.ts +18 -149
- package/src/op/gate-point.e2e.test.ts +2 -0
- package/src/op/gate-point.test.ts +1 -1
- package/src/op/gate-point.ts +27 -3
- package/src/op/gate.ts +10 -0
- package/src/op/index.ts +10 -1
- package/src/op/op-waves-run.ts +84 -19
- package/src/op/op-waves.test.ts +96 -0
- package/src/op/op-waves.ts +60 -4
- package/src/op/steward-points.ts +8 -0
- package/src/override-gate.test.ts +5 -0
- package/src/override-gate.ts +8 -2
- package/src/point-decision-bundle.test.ts +62 -0
- package/src/point-decision.e2e.test.ts +95 -0
- package/src/point-decision.ts +280 -0
- package/src/pr-forge.test.ts +62 -0
- package/src/pr-forge.ts +60 -9
- package/src/workspace/decide.ts +7 -2
- package/src/workspace/points-cli.ts +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3816: a tool built into one file decides a gate that asks a decision point
|
|
3
|
+
* with `@intentius/chant/point-decision`, asking through the chant bin
|
|
4
|
+
* (`chantGatePointAsker`) and recording through its own gate ledger port.
|
|
5
|
+
* The question is open until a person answers it with `chant workspace points
|
|
6
|
+
* answer`; then the same call records the resolution that cites the answer,
|
|
7
|
+
* once per answer and plan. With `store: "ledger"` the question is held on
|
|
8
|
+
* `chant/lifecycle`, not in the working tree (#3815).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
|
15
|
+
import { cleanScratch, workspace } from "./workspace/__fixtures__/decide-workspace";
|
|
16
|
+
import { defaultChantCommand } from "./workspace/conformance";
|
|
17
|
+
import { memoryGateLedgerPort } from "./op/gate";
|
|
18
|
+
import { chantGatePointAsker, evaluatePointGate, type PointGateCheck } from "./point-decision";
|
|
19
|
+
|
|
20
|
+
const PLAN = `jcs1-sha256:${"5".repeat(64)}`;
|
|
21
|
+
|
|
22
|
+
const POINTS = {
|
|
23
|
+
points: {
|
|
24
|
+
"prod-apply": {
|
|
25
|
+
title: "Apply this plan to prod",
|
|
26
|
+
question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
|
|
27
|
+
inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
|
|
28
|
+
deciders: [{ kind: "quorum", count: 1 }],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const command = defaultChantCommand();
|
|
34
|
+
let root: string;
|
|
35
|
+
|
|
36
|
+
const chant = (...args: string[]): Record<string, unknown> => {
|
|
37
|
+
let out: string;
|
|
38
|
+
try {
|
|
39
|
+
out = execFileSync(command[0], [...command.slice(1), ...args], { cwd: root, encoding: "utf-8", env: { ...process.env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
out = String((e as { stdout?: string }).stdout ?? "");
|
|
42
|
+
}
|
|
43
|
+
return JSON.parse(out) as Record<string, unknown>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const answersInTree = (): string[] => (existsSync(join(root, "answers")) ? readdirSync(join(root, "answers")).filter((f) => f.endsWith(".md")) : []);
|
|
47
|
+
|
|
48
|
+
beforeAll(() => {
|
|
49
|
+
root = workspace(POINTS);
|
|
50
|
+
vi.stubEnv("CI", "");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
vi.unstubAllEnvs();
|
|
55
|
+
cleanScratch();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("a gate-point decision through the chant bin (#3816)", () => {
|
|
59
|
+
test("waits on the open question, then passes on a person's answer, once per answer and plan", async () => {
|
|
60
|
+
const port = memoryGateLedgerPort();
|
|
61
|
+
const asker = chantGatePointAsker({ command, cwd: root });
|
|
62
|
+
const input = { op: "tf-apply", gate: "approve-apply", point: { name: "prod-apply" }, planDigest: PLAN, now: "2026-10-11T13:00:00.000Z" };
|
|
63
|
+
|
|
64
|
+
const first = await evaluatePointGate(port, asker, input);
|
|
65
|
+
expect(first).toMatchObject({ satisfied: false, recorded: true, waiting: { point: "prod-apply", state: "escalated", subject: "gate:tf-apply/approve-apply" } });
|
|
66
|
+
const waiting = first as Extract<PointGateCheck, { waiting: unknown }>;
|
|
67
|
+
expect(port.appended).toEqual([expect.objectContaining({ op: "tf-apply", gate: "approve-apply", planDigest: PLAN, answer: { point: "prod-apply", id: waiting.waiting.id, path: waiting.waiting.path } })]);
|
|
68
|
+
|
|
69
|
+
// Asked again while open, the standing pending fact is reused.
|
|
70
|
+
expect(await evaluatePointGate(port, asker, input)).toMatchObject({ satisfied: false, recorded: false });
|
|
71
|
+
|
|
72
|
+
expect(chant("workspace", "points", "answer", waiting.waiting.id, "--answer", "yes", "--by", "alice")).toMatchObject({ written: true, question: { state: "answered" } });
|
|
73
|
+
|
|
74
|
+
const passed = await evaluatePointGate(port, asker, input);
|
|
75
|
+
expect(passed).toMatchObject({ satisfied: true, recorded: true, resolution: { resolvedBy: "alice", planDigest: PLAN, answer: { id: waiting.waiting.id, answer: true, decider: "quorum", answeredBy: ["alice"] } } });
|
|
76
|
+
expect(await evaluatePointGate(port, asker, input)).toMatchObject({ satisfied: true, recorded: false });
|
|
77
|
+
expect(port.resolved).toHaveLength(1);
|
|
78
|
+
}, 120_000);
|
|
79
|
+
|
|
80
|
+
test("a point with store ledger holds the question on chant/lifecycle, and an answer it does not pass on refuses", async () => {
|
|
81
|
+
const port = memoryGateLedgerPort();
|
|
82
|
+
const asker = chantGatePointAsker({ command, cwd: root });
|
|
83
|
+
const before = answersInTree();
|
|
84
|
+
const input = { op: "ledger-apply", gate: "approve-apply", point: { name: "prod-apply", store: "ledger" as const }, planDigest: PLAN };
|
|
85
|
+
|
|
86
|
+
const first = (await evaluatePointGate(port, asker, input)) as Extract<PointGateCheck, { waiting: unknown }>;
|
|
87
|
+
expect(first.waiting.state).toBe("escalated");
|
|
88
|
+
expect(answersInTree()).toEqual(before);
|
|
89
|
+
|
|
90
|
+
chant("workspace", "points", "answer", first.waiting.id, "--answer", "no", "--by", "alice");
|
|
91
|
+
const refused = await evaluatePointGate(port, asker, input);
|
|
92
|
+
expect(refused).toMatchObject({ satisfied: false, answer: { id: first.waiting.id, answer: false } });
|
|
93
|
+
expect("refused" in refused && refused.refused).toContain("passes only on true");
|
|
94
|
+
}, 120_000);
|
|
95
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision for a gate that asks a decision point (#3170), for a tool that
|
|
3
|
+
* runs from one bundled file (#3816).
|
|
4
|
+
*
|
|
5
|
+
* `@intentius/chant/point-decision` holds chant's rule for such a gate: ask
|
|
6
|
+
* the point with the gate's `gate.*` facts ({@link gatePointInputs}, under
|
|
7
|
+
* {@link gateSubject}), then
|
|
8
|
+
*
|
|
9
|
+
* - the question is open: record a pending fact that cites it, once per
|
|
10
|
+
* question and plan, and wait;
|
|
11
|
+
* - it is answered with an answer the gate passes on (`pass`, default yes):
|
|
12
|
+
* record a resolution that cites the answer, once per answer and plan, and
|
|
13
|
+
* go through;
|
|
14
|
+
* - it is answered otherwise: refuse, naming the record.
|
|
15
|
+
*
|
|
16
|
+
* Both halves are injected. A {@link GatePointAsker} asks the point (chant's
|
|
17
|
+
* own, `workspaceGatePointAsker` in `@intentius/chant/op`, reads the
|
|
18
|
+
* workspace; {@link chantGatePointAsker} runs the chant bin instead), and a
|
|
19
|
+
* {@link PointGateLedgerPort} reads and appends the gate ledger. So this module imports no workspace, CLI,
|
|
20
|
+
* TypeScript, zod or git code, which `point-decision-bundle.test.ts` holds
|
|
21
|
+
* it to. `./op/gate-point-run.ts` re-exports it, and chant's own runs decide
|
|
22
|
+
* by it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { isPendingGateExpired, latestPendingGate } from "./lifecycle/gate-ledger-select";
|
|
27
|
+
import { DEFAULT_GATE_EXPIRY, resolveApprovalUrl } from "./lifecycle/gate-pending";
|
|
28
|
+
import { samePlanDigest } from "./lifecycle/plan-digest";
|
|
29
|
+
import type { GateAnswerRef, GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
|
|
30
|
+
import { parseDuration } from "./op/duration";
|
|
31
|
+
import type { GateLedgerPort } from "./op/gate";
|
|
32
|
+
import { DEFAULT_GATE_POINT_PASS, defaultGatePointStore, gatePointInputs, gateSubject, type GatePoint, type GatePointFacts, type GatePointStore } from "./op/gate-point";
|
|
33
|
+
import type { WaitingPoint } from "./op/steward-points";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
DEFAULT_GATE_POINT_PASS,
|
|
37
|
+
GATE_POINT_INPUTS,
|
|
38
|
+
defaultGatePointStore,
|
|
39
|
+
gatePointInputs,
|
|
40
|
+
gatePointOf,
|
|
41
|
+
gatePointProblems,
|
|
42
|
+
gateSubject,
|
|
43
|
+
type GatePoint,
|
|
44
|
+
type GatePointFacts,
|
|
45
|
+
type GatePointStore,
|
|
46
|
+
} from "./op/gate-point";
|
|
47
|
+
export type { GateAnswerRef, GateResolutionRecord, GateResolutionInput, PendingGateInput, PendingGateRecord } from "./lifecycle/gate-ledger";
|
|
48
|
+
export type { WaitingPoint } from "./op/steward-points";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The gate ledger as {@link evaluatePointGate} uses it: read an op's facts,
|
|
52
|
+
* append a pending fact, append a resolution. chant's own is
|
|
53
|
+
* `gitGateLedgerPort` (`@intentius/chant/op`), on `chant/lifecycle`.
|
|
54
|
+
*/
|
|
55
|
+
export type PointGateLedgerPort = Pick<GateLedgerPort, "read" | "appendPending" | "appendResolution">;
|
|
56
|
+
|
|
57
|
+
/** The question a gate's point was asked, as the gate reads it. */
|
|
58
|
+
export interface GatePointQuestion {
|
|
59
|
+
/** The answer record's id: what `points answer` takes. */
|
|
60
|
+
id: string;
|
|
61
|
+
point: string;
|
|
62
|
+
/** The record, from the repository root. */
|
|
63
|
+
path: string;
|
|
64
|
+
state: "escalated" | "proposed" | "answered";
|
|
65
|
+
/** The answer, or null while the question is open. */
|
|
66
|
+
answer: string | boolean | null;
|
|
67
|
+
/** The decider that gave the recorded state: table, model or quorum. */
|
|
68
|
+
decider: string;
|
|
69
|
+
answeredBy: string[];
|
|
70
|
+
subject: string | null;
|
|
71
|
+
/** The steward whose turn asked it, or null. */
|
|
72
|
+
steward: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a gate asks its point with. */
|
|
76
|
+
export interface GatePointRequest {
|
|
77
|
+
point: string;
|
|
78
|
+
/** The authored inputs, references resolved. */
|
|
79
|
+
inputs: Record<string, unknown>;
|
|
80
|
+
facts: GatePointFacts;
|
|
81
|
+
/** What the answer record's `constrains` names: {@link gateSubject}. */
|
|
82
|
+
subject: string;
|
|
83
|
+
/** Where the question goes, as the gate's `point.store` declares it (#3815). Undefined leaves it to the asker. */
|
|
84
|
+
store?: GatePointStore;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How a gate asks its point. A test passes a stub; `workspaceGatePointAsker`
|
|
89
|
+
* (`@intentius/chant/op`) is chant's, which asks through the workspace. A tool
|
|
90
|
+
* built into one file passes its own, such as one that runs the chant bin.
|
|
91
|
+
*/
|
|
92
|
+
export interface GatePointAsker {
|
|
93
|
+
ask(request: GatePointRequest): Promise<GatePointQuestion>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What the run needs to decide a gate that asks a point. */
|
|
97
|
+
export interface PointGateInput {
|
|
98
|
+
op: string;
|
|
99
|
+
gate: string;
|
|
100
|
+
point: GatePoint;
|
|
101
|
+
/** The authored inputs, references resolved. */
|
|
102
|
+
inputs?: Record<string, unknown>;
|
|
103
|
+
description?: string;
|
|
104
|
+
timeout?: string;
|
|
105
|
+
runId?: string;
|
|
106
|
+
planDigest?: string;
|
|
107
|
+
/** The environment the run was started for: the `gate.env` input. Op gate facts on the ledger record none. */
|
|
108
|
+
env?: string;
|
|
109
|
+
now?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What reaching a gate that asks a point decided. */
|
|
113
|
+
export type PointGateCheck =
|
|
114
|
+
| {
|
|
115
|
+
satisfied: true;
|
|
116
|
+
/** The resolution that cites the answer: this run's, or one an earlier run wrote for the same answer and plan. */
|
|
117
|
+
resolution: GateResolutionRecord;
|
|
118
|
+
answer: GateAnswerRef;
|
|
119
|
+
/** Whether this run appended the resolution. */
|
|
120
|
+
recorded: boolean;
|
|
121
|
+
pushed?: boolean;
|
|
122
|
+
pushWarning?: string;
|
|
123
|
+
}
|
|
124
|
+
| {
|
|
125
|
+
satisfied: false;
|
|
126
|
+
/** The question is answered, with an answer the gate does not pass on. */
|
|
127
|
+
refused: string;
|
|
128
|
+
answer: GateAnswerRef;
|
|
129
|
+
}
|
|
130
|
+
| {
|
|
131
|
+
satisfied: false;
|
|
132
|
+
/** The open question the run waits on. */
|
|
133
|
+
waiting: WaitingPoint;
|
|
134
|
+
/** The pending fact that cites it. */
|
|
135
|
+
pending: PendingGateRecord;
|
|
136
|
+
recorded: boolean;
|
|
137
|
+
pushed?: boolean;
|
|
138
|
+
pushWarning?: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Who a resolution written for an answer is by: the people who answered, or the decider that did. */
|
|
142
|
+
function resolvedByOf(q: GatePointQuestion): string {
|
|
143
|
+
return q.answeredBy.length > 0 ? q.answeredBy.join(", ") : q.decider;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Decide a gate that asks a point: ask it, then record the pending fact or the resolution that cites the answer. */
|
|
147
|
+
export async function evaluatePointGate(port: PointGateLedgerPort, asker: GatePointAsker, input: PointGateInput): Promise<PointGateCheck> {
|
|
148
|
+
const now = input.now ?? new Date().toISOString();
|
|
149
|
+
const facts: GatePointFacts = { component: input.op, name: input.gate, env: input.env ?? null, planDigest: input.planDigest ?? null };
|
|
150
|
+
const q = await asker.ask({ point: input.point.name, inputs: input.inputs ?? {}, facts, subject: gateSubject(input.op, input.gate, input.env), ...(input.point.store ? { store: input.point.store } : {}) });
|
|
151
|
+
const ref: GateAnswerRef = { point: q.point, id: q.id, path: q.path };
|
|
152
|
+
|
|
153
|
+
if (q.state === "answered" && q.answer !== null) {
|
|
154
|
+
const answer: GateAnswerRef = { ...ref, answer: q.answer, decider: q.decider, ...(q.answeredBy.length > 0 ? { answeredBy: q.answeredBy } : {}) };
|
|
155
|
+
const pass = input.point.pass ?? DEFAULT_GATE_POINT_PASS;
|
|
156
|
+
if (!pass.includes(q.answer)) {
|
|
157
|
+
return {
|
|
158
|
+
satisfied: false,
|
|
159
|
+
answer,
|
|
160
|
+
refused:
|
|
161
|
+
`decision point ${q.point} answered ${JSON.stringify(q.answer)} (${q.id}, by ${resolvedByOf(q)}), and gate "${input.gate}" passes only on ${pass.map((a) => JSON.stringify(a)).join(" or ")}. ` +
|
|
162
|
+
`To ask again, take the answer back: chant workspace points retract ${q.id} --by <name>`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const ledger = await port.read(input.op);
|
|
166
|
+
const standing = ledger.resolutions.find(
|
|
167
|
+
(r) => r.gate === input.gate && r.answer?.id === q.id && r.environment === undefined && samePlanDigest(r.planDigest, input.planDigest),
|
|
168
|
+
);
|
|
169
|
+
if (standing) return { satisfied: true, resolution: standing, answer, recorded: false };
|
|
170
|
+
if (!port.appendResolution) throw new Error(`gate "${input.gate}" asks decision point ${q.point}, and this gate ledger cannot record the resolution that cites its answer`);
|
|
171
|
+
const { record, pushed, pushWarning } = await port.appendResolution({
|
|
172
|
+
op: input.op,
|
|
173
|
+
gate: input.gate,
|
|
174
|
+
resolvedBy: resolvedByOf(q),
|
|
175
|
+
timestamp: now,
|
|
176
|
+
note: `answered through decision point ${q.point} (${q.id})`,
|
|
177
|
+
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
178
|
+
approver: { kind: q.decider === "quorum" || q.answeredBy.length > 0 ? "human" : "agent" },
|
|
179
|
+
answer,
|
|
180
|
+
});
|
|
181
|
+
return { satisfied: true, resolution: record, answer, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const waiting: WaitingPoint = { id: q.id, point: q.point, state: q.state === "proposed" ? "proposed" : "escalated", path: q.path, subject: q.subject, steward: q.steward };
|
|
185
|
+
const ledger = await port.read(input.op);
|
|
186
|
+
const standing = latestPendingGate(ledger.pending.filter((p) => p.environment === undefined), input.gate);
|
|
187
|
+
if (standing && !isPendingGateExpired(standing, now) && samePlanDigest(standing.planDigest, input.planDigest) && standing.answer?.id === q.id) {
|
|
188
|
+
return { satisfied: false, waiting, pending: standing, recorded: false };
|
|
189
|
+
}
|
|
190
|
+
const url = resolveApprovalUrl();
|
|
191
|
+
const { record, pushed, pushWarning } = await port.appendPending({
|
|
192
|
+
op: input.op,
|
|
193
|
+
gate: input.gate,
|
|
194
|
+
timestamp: now,
|
|
195
|
+
expiresAt: new Date(new Date(now).getTime() + parseDuration(input.timeout ?? DEFAULT_GATE_EXPIRY)).toISOString(),
|
|
196
|
+
...(input.description ? { description: input.description } : {}),
|
|
197
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
198
|
+
...(url ? { url } : {}),
|
|
199
|
+
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
200
|
+
answer: ref,
|
|
201
|
+
});
|
|
202
|
+
return { satisfied: false, waiting, pending: record, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** How {@link chantGatePointAsker} runs chant. */
|
|
206
|
+
export interface ChantGatePointAskerOptions {
|
|
207
|
+
/** The chant command and its leading arguments, such as `["npx", "chant"]` or `[process.execPath, "/path/to/chant"]`. */
|
|
208
|
+
command: string[];
|
|
209
|
+
/** Where the workspace is found. Defaults to the working directory. */
|
|
210
|
+
cwd?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Run chant with `args` and parse the JSON document it prints. A refusal is a document too, and exits 1. */
|
|
214
|
+
function runChantJson(opts: ChantGatePointAskerOptions, args: string[]): Promise<Record<string, unknown>> {
|
|
215
|
+
const [bin, ...lead] = opts.command;
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
execFile(bin, [...lead, ...args], { cwd: opts.cwd, encoding: "utf-8", maxBuffer: 16 * 1024 * 1024, env: { ...process.env, NO_COLOR: "1" } }, (err, stdout, stderr) => {
|
|
218
|
+
try {
|
|
219
|
+
resolve(JSON.parse(stdout) as Record<string, unknown>);
|
|
220
|
+
} catch {
|
|
221
|
+
reject(new Error(`chant ${args.slice(0, 3).join(" ")} printed no JSON document${err ? `: ${err.message}` : ""}${stderr ? `\n${stderr.trim()}` : ""}`));
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function refusal(what: string, doc: Record<string, unknown>): Error | undefined {
|
|
228
|
+
const e = doc.error as { code?: string; message?: string } | undefined;
|
|
229
|
+
return e ? new Error(`${what}: ${e.code}: ${e.message}`) : undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A {@link GatePointAsker} that runs the chant bin, for a tool that bundles
|
|
234
|
+
* this module and not the workspace code (#3816). It reads the point's
|
|
235
|
+
* declared inputs with `chant workspace points --json`, passes the gate's
|
|
236
|
+
* `gate.*` ones ({@link gatePointInputs}), and asks with `chant workspace
|
|
237
|
+
* points ask <point> --inputs <json> --subject <gate> --json`, with `--store`
|
|
238
|
+
* from the gate's `point.store` or, naming none, the ledger in a CI job
|
|
239
|
+
* ({@link defaultGatePointStore}). chant calls no model here, so a point's
|
|
240
|
+
* model decider is not asked and its question goes to people; a table and a
|
|
241
|
+
* quorum decide as in chant's own runs.
|
|
242
|
+
*/
|
|
243
|
+
export function chantGatePointAsker(opts: ChantGatePointAskerOptions): GatePointAsker {
|
|
244
|
+
return {
|
|
245
|
+
async ask(request) {
|
|
246
|
+
const listed = await runChantJson(opts, ["workspace", "points", "--json"]);
|
|
247
|
+
const bad = refusal(`decision point ${request.point}`, listed);
|
|
248
|
+
if (bad) throw bad;
|
|
249
|
+
const point = (listed.points as { name: string; inputs: { name: string }[] }[] | undefined)?.find((p) => p.name === request.point);
|
|
250
|
+
if (!point) throw new Error(`gate "${request.facts.name}" asks decision point ${request.point}, and no points file declares it`);
|
|
251
|
+
const inputs = gatePointInputs(request.point, point.inputs.map((i) => i.name), request.facts, request.inputs);
|
|
252
|
+
const store = request.store ?? defaultGatePointStore();
|
|
253
|
+
const doc = await runChantJson(opts, ["workspace", "points", "ask", request.point, "--inputs", JSON.stringify(inputs), "--subject", request.subject, ...(store ? ["--store", store] : [])]);
|
|
254
|
+
const failed = refusal(`decision point ${request.point}`, doc);
|
|
255
|
+
if (failed) throw failed;
|
|
256
|
+
const q = doc.question as {
|
|
257
|
+
id: string;
|
|
258
|
+
point: string;
|
|
259
|
+
path: string;
|
|
260
|
+
state: GatePointQuestion["state"];
|
|
261
|
+
answer: string | boolean | null;
|
|
262
|
+
decider: { kind?: unknown };
|
|
263
|
+
answeredBy: string[];
|
|
264
|
+
subject: string | null;
|
|
265
|
+
askedBy: { steward: string } | null;
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
id: q.id,
|
|
269
|
+
point: q.point,
|
|
270
|
+
path: q.path,
|
|
271
|
+
state: q.state,
|
|
272
|
+
answer: q.state === "answered" ? q.answer : null,
|
|
273
|
+
decider: typeof q.decider?.kind === "string" ? q.decider.kind : "quorum",
|
|
274
|
+
answeredBy: q.answeredBy ?? [],
|
|
275
|
+
subject: q.subject,
|
|
276
|
+
steward: q.askedBy?.steward ?? null,
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
package/src/pr-forge.test.ts
CHANGED
|
@@ -613,3 +613,65 @@ describe("forgeTargetFromEnv (#3759)", () => {
|
|
|
613
613
|
expect(() => forgeTargetFromEnv({})).toThrow(/forge is not known/);
|
|
614
614
|
});
|
|
615
615
|
});
|
|
616
|
+
|
|
617
|
+
describe("addComment and findIssue (#3818)", () => {
|
|
618
|
+
const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
|
|
619
|
+
|
|
620
|
+
test("addComment posts a new comment every time, with no read and no marker", async () => {
|
|
621
|
+
const { fetch, calls } = fakeFetch({ "POST /repos/acme/infra/issues/12/comments": { id: 9, html_url: "https://h/9" } });
|
|
622
|
+
const forge = githubForge({ ...gh, fetch });
|
|
623
|
+
expect(await forge.addComment(12, "terragucci: locked")).toEqual({ id: 9, url: "https://h/9" });
|
|
624
|
+
await forge.addComment(12, "terragucci: locked");
|
|
625
|
+
expect(calls.map((c) => c.method)).toEqual(["POST", "POST"]);
|
|
626
|
+
expect(calls[0].body).toEqual({ body: "terragucci: locked" });
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("addComment on Forgejo and GitLab", async () => {
|
|
630
|
+
const fj = fakeFetch({ "POST /api/v1/repos/acme/infra/issues/4/comments": { id: 2, html_url: "https://cb/2" } });
|
|
631
|
+
expect(await forgejoForge({ ...gh, apiBase: "https://codeberg.org/api/v1", host: "codeberg.org", fetch: fj.fetch }).addComment(4, "hi")).toEqual({ id: 2, url: "https://cb/2" });
|
|
632
|
+
const gl = fakeFetch({ "POST /api/v4/projects/42/merge_requests/5/notes": { id: 77, body: "hi" } });
|
|
633
|
+
const r = await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: gl.fetch }).addComment(5, "hi");
|
|
634
|
+
expect(r).toEqual({ id: 77 });
|
|
635
|
+
expect(gl.calls).toHaveLength(1);
|
|
636
|
+
expect(gl.calls[0].body).toEqual({ body: "hi" });
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("trackIssue says when the issue was opened", async () => {
|
|
640
|
+
const open = { number: 8, title: "Drift", body: "<!-- d -->\nold", html_url: "https://h/issues/8", created_at: "2026-10-01T00:00:00Z" };
|
|
641
|
+
const upd = fakeFetch({ "GET /repos/acme/infra/issues": [open], "PATCH /repos/acme/infra/issues/8": { ...open, body: "<!-- d -->\nnew" } });
|
|
642
|
+
expect(await githubForge({ ...gh, fetch: upd.fetch }).trackIssue("<!-- d -->", { title: "Drift", body: "<!-- d -->\nnew" })).toEqual({
|
|
643
|
+
action: "updated", number: 8, url: "https://h/issues/8", created: "2026-10-01T00:00:00Z",
|
|
644
|
+
});
|
|
645
|
+
const made = fakeFetch({ "GET /repos/acme/infra/issues": [], "POST /repos/acme/infra/issues": { ...open, number: 9, html_url: "https://h/issues/9", created_at: "2026-10-11T00:00:00Z" } });
|
|
646
|
+
expect(await githubForge({ ...gh, fetch: made.fetch }).trackIssue("<!-- d -->", { title: "Drift", body: "<!-- d -->\nb" })).toMatchObject({ action: "opened", number: 9, created: "2026-10-11T00:00:00Z" });
|
|
647
|
+
const gl = fakeFetch({ "GET /api/v4/projects/42/issues": [{ iid: 3, title: "Drift", description: "<!-- d -->\nold", web_url: "https://gl/i/3", created_at: "2026-09-30T00:00:00Z" }] });
|
|
648
|
+
const r = await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: gl.fetch }).trackIssue("<!-- d -->", null);
|
|
649
|
+
expect(r).toEqual({ action: "left", number: 3, url: "https://gl/i/3", created: "2026-09-30T00:00:00Z" });
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
test("findIssue reads the oldest open match with no write and no token", async () => {
|
|
653
|
+
const { fetch, calls } = fakeFetch({
|
|
654
|
+
"GET /repos/acme/infra/issues": [
|
|
655
|
+
{ number: 5, title: "PR", body: "<!-- d -->", pull_request: {} },
|
|
656
|
+
{ number: 7, title: "newer", body: "<!-- d -->\nb", user: { login: "bot" }, created_at: "2026-10-05T00:00:00Z", html_url: "https://h/issues/7" },
|
|
657
|
+
{ number: 6, title: "planted", body: "<!-- d -->\nx", user: { login: "mallory" }, created_at: "2026-10-04T00:00:00Z" },
|
|
658
|
+
],
|
|
659
|
+
});
|
|
660
|
+
const forge = githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", fetch });
|
|
661
|
+
expect(await forge.findIssue("<!-- d -->", { author: "bot" })).toEqual({ number: 7, title: "newer", body: "<!-- d -->\nb", created: "2026-10-05T00:00:00Z", url: "https://h/issues/7" });
|
|
662
|
+
expect((await forge.findIssue("<!-- d -->"))?.number).toBe(6);
|
|
663
|
+
expect(await forge.findIssue("<!-- none -->")).toBeUndefined();
|
|
664
|
+
expect(calls.every((c) => c.method === "GET" && !("Authorization" in c.headers))).toBe(true);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("findIssue on GitLab and Forgejo, tokenless", async () => {
|
|
668
|
+
const gl = fakeFetch({ "GET /api/v4/projects/42/issues": [{ iid: 3, title: "Drift", description: "<!-- d -->\nb", web_url: "https://gl/i/3", created_at: "2026-09-30T00:00:00Z" }] });
|
|
669
|
+
const g = gitlabForge({ apiBase: "https://gl/api/v4", project: "42", fetch: gl.fetch });
|
|
670
|
+
expect(await g.findIssue("<!-- d -->")).toEqual({ number: 3, title: "Drift", body: "<!-- d -->\nb", created: "2026-09-30T00:00:00Z", url: "https://gl/i/3" });
|
|
671
|
+
expect(Object.keys(gl.calls[0].headers)).toEqual([]);
|
|
672
|
+
const fj = fakeFetch({ "GET /api/v1/repos/acme/infra/issues": [{ number: 2, title: "t", body: "<!-- d -->", created_at: "2026-10-02T00:00:00Z" }] });
|
|
673
|
+
const f = forgejoForge({ apiBase: "https://codeberg.org/api/v1", repo: "acme/infra", host: "codeberg.org", fetch: fj.fetch });
|
|
674
|
+
expect((await f.findIssue("<!-- d -->"))?.created).toBe("2026-10-02T00:00:00Z");
|
|
675
|
+
expect(fj.calls[0].headers.Authorization).toBeUndefined();
|
|
676
|
+
});
|
|
677
|
+
});
|
package/src/pr-forge.ts
CHANGED
|
@@ -85,6 +85,25 @@ export interface FoundComment {
|
|
|
85
85
|
url?: string;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** What {@link PrForge.addComment} posted. */
|
|
89
|
+
export interface AddedComment {
|
|
90
|
+
/** The new comment's or note's id. */
|
|
91
|
+
id: number;
|
|
92
|
+
/** Its web URL, when the forge's answer carries one (GitHub and Forgejo do; a GitLab note does not). */
|
|
93
|
+
url?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** An open tracking issue {@link PrForge.findIssue} read back. */
|
|
97
|
+
export interface FoundIssue {
|
|
98
|
+
/** The issue's number (GitHub, Forgejo) or iid (GitLab). */
|
|
99
|
+
number: number;
|
|
100
|
+
title: string;
|
|
101
|
+
body: string;
|
|
102
|
+
/** When the issue was opened: the forge's `created_at`, an ISO 8601 string. */
|
|
103
|
+
created?: string;
|
|
104
|
+
url?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
/** A finding to keep in a tracking issue. `body` carries the marker. */
|
|
89
108
|
export interface IssueFinding {
|
|
90
109
|
title: string;
|
|
@@ -123,6 +142,8 @@ export interface IssueResult {
|
|
|
123
142
|
/** The issue's number (GitHub, Forgejo) or iid (GitLab). */
|
|
124
143
|
number?: number;
|
|
125
144
|
url?: string;
|
|
145
|
+
/** When the issue was opened (the forge's `created_at`, ISO 8601), for an issue that exists after the call or was just closed. */
|
|
146
|
+
created?: string;
|
|
126
147
|
}
|
|
127
148
|
|
|
128
149
|
export interface PrForge {
|
|
@@ -155,6 +176,13 @@ export interface PrForge {
|
|
|
155
176
|
* GitLab's system notes are skipped.
|
|
156
177
|
*/
|
|
157
178
|
findComment(pr: number, marker: string, opts?: MarkerMatch): Promise<FoundComment | undefined>;
|
|
179
|
+
/**
|
|
180
|
+
* Post a new comment on pull request `pr` (a new note on merge request
|
|
181
|
+
* `pr`). No marker is looked for or required, and an earlier comment is
|
|
182
|
+
* never edited: every call adds one. For replies, where
|
|
183
|
+
* {@link upsertComment} would overwrite the last.
|
|
184
|
+
*/
|
|
185
|
+
addComment(pr: number, body: string): Promise<AddedComment>;
|
|
158
186
|
setStatus(sha: string, status: ForgeStatus): Promise<void>;
|
|
159
187
|
/**
|
|
160
188
|
* Keep one OPEN tracking issue for `marker` in line with `finding`: open
|
|
@@ -164,6 +192,13 @@ export interface PrForge {
|
|
|
164
192
|
* taken for issues. When several match, the oldest is kept.
|
|
165
193
|
*/
|
|
166
194
|
trackIssue(marker: string, finding: IssueFinding | null, opts?: TrackIssueOptions): Promise<IssueResult>;
|
|
195
|
+
/**
|
|
196
|
+
* The OPEN issue {@link trackIssue} would keep for `marker`: the oldest one
|
|
197
|
+
* {@link MarkerMatch} finds, `author` honoured, pull requests left out.
|
|
198
|
+
* Undefined when there is none. Writes nothing, so a client built without a
|
|
199
|
+
* token reads a public repository's issue with it.
|
|
200
|
+
*/
|
|
201
|
+
findIssue(marker: string, opts?: MarkerMatch): Promise<FoundIssue | undefined>;
|
|
167
202
|
/**
|
|
168
203
|
* {@link trackIssue} by prefix for a finding, without the body check,
|
|
169
204
|
* resolving to the issue's web URL. `reconcilePr`'s `issue` mode's form.
|
|
@@ -247,6 +282,8 @@ interface Thread {
|
|
|
247
282
|
title?: string;
|
|
248
283
|
author?: string;
|
|
249
284
|
url?: string;
|
|
285
|
+
/** An issue's `created_at`. */
|
|
286
|
+
created?: string;
|
|
250
287
|
}
|
|
251
288
|
|
|
252
289
|
/** The calls each forge makes for the shared comment and issue recipes. */
|
|
@@ -298,7 +335,8 @@ async function upsertCommentWith(t: Threads, pr: number, marker: string, body: s
|
|
|
298
335
|
async function trackIssueWith(t: Threads, marker: string, finding: IssueFinding | null, opts: TrackIssueOptions, check: boolean): Promise<IssueResult> {
|
|
299
336
|
if (finding && check) checkMarker("issue", marker, finding.body, opts.match);
|
|
300
337
|
const mine = (await t.openIssues(marker)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
|
|
301
|
-
const at = (i: Thread | undefined): Omit<IssueResult, "action"> =>
|
|
338
|
+
const at = (i: Thread | undefined): Omit<IssueResult, "action"> =>
|
|
339
|
+
i ? { number: i.id, ...(i.url ? { url: i.url } : {}), ...(i.created ? { created: i.created } : {}) } : {};
|
|
302
340
|
if (finding === null) {
|
|
303
341
|
if (!mine) return { action: "none" };
|
|
304
342
|
if (!opts.closeOnClear) return { action: "left", ...at(mine) };
|
|
@@ -318,8 +356,17 @@ function sameIssue(open: IssueFinding, finding: IssueFinding, sameWhen: TrackIss
|
|
|
318
356
|
}
|
|
319
357
|
|
|
320
358
|
/** The recipes every client shares, over its own {@link Threads}. */
|
|
321
|
-
function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "findComment" | "trackIssue" | "upsertIssue"> {
|
|
359
|
+
function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "findComment" | "addComment" | "trackIssue" | "findIssue" | "upsertIssue"> {
|
|
322
360
|
return {
|
|
361
|
+
async addComment(pr, body) {
|
|
362
|
+
const made = await t.addComment(pr, body);
|
|
363
|
+
return { id: made?.id ?? 0, ...(made?.url ? { url: made.url } : {}) };
|
|
364
|
+
},
|
|
365
|
+
async findIssue(marker, opts = {}) {
|
|
366
|
+
if (!marker) throw new Error("the issue needs a marker to be found");
|
|
367
|
+
const i = (await t.openIssues(marker)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
|
|
368
|
+
return i ? { number: i.id, title: i.title ?? "", body: i.body, ...(i.created ? { created: i.created } : {}), ...(i.url ? { url: i.url } : {}) } : undefined;
|
|
369
|
+
},
|
|
323
370
|
async findComment(pr, marker, opts = {}) {
|
|
324
371
|
const c = await ownComment(t, pr, marker, opts);
|
|
325
372
|
return c ? { id: c.id, body: c.body, ...(c.url ? { url: c.url } : {}) } : undefined;
|
|
@@ -336,7 +383,8 @@ export interface GithubForgeOptions {
|
|
|
336
383
|
apiBase: string;
|
|
337
384
|
/** `owner/name`. */
|
|
338
385
|
repo: string;
|
|
339
|
-
|
|
386
|
+
/** Without one, no credential is sent: enough to read a public repository ({@link PrForge.findComment}, {@link PrForge.findIssue}). */
|
|
387
|
+
token?: string;
|
|
340
388
|
/** The forge's host, for naming reviewers. Default github.com. */
|
|
341
389
|
host?: string;
|
|
342
390
|
runUrl?: string;
|
|
@@ -354,8 +402,8 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
354
402
|
const api = transport(
|
|
355
403
|
opts.apiBase,
|
|
356
404
|
kind === "github"
|
|
357
|
-
? { Authorization: `Bearer ${opts.token}
|
|
358
|
-
: { Authorization: `token ${opts.token}
|
|
405
|
+
? { ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}), Accept: "application/vnd.github+json" }
|
|
406
|
+
: { ...(opts.token ? { Authorization: `token ${opts.token}` } : {}), Accept: "application/json" },
|
|
359
407
|
opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch),
|
|
360
408
|
);
|
|
361
409
|
const repo = `/repos/${opts.repo}`;
|
|
@@ -368,7 +416,8 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
368
416
|
if (id === undefined) return undefined;
|
|
369
417
|
const author = loginOf(v);
|
|
370
418
|
const url = str(v.html_url);
|
|
371
|
-
|
|
419
|
+
const created = str(v.created_at);
|
|
420
|
+
return { id, body: str(v.body) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
372
421
|
};
|
|
373
422
|
const threads = (items: unknown[]): Thread[] => items.map(thread).filter((t): t is Thread => t !== undefined);
|
|
374
423
|
const t: Threads = {
|
|
@@ -452,7 +501,8 @@ export interface GitlabForgeOptions {
|
|
|
452
501
|
apiBase: string;
|
|
453
502
|
/** `$CI_PROJECT_ID`, or the URL-encoded path. */
|
|
454
503
|
project: string;
|
|
455
|
-
|
|
504
|
+
/** Without one, no credential is sent: enough to read a public project ({@link PrForge.findComment}, {@link PrForge.findIssue}). */
|
|
505
|
+
token?: string;
|
|
456
506
|
/**
|
|
457
507
|
* The header the token goes in: `PRIVATE-TOKEN` (the default) for an
|
|
458
508
|
* access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. One in the other's header
|
|
@@ -474,7 +524,7 @@ const GITLAB_STATE: Record<ForgeStatus["state"], string> = { pending: "pending",
|
|
|
474
524
|
*/
|
|
475
525
|
export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
476
526
|
const host = (opts.host ?? "gitlab.com").toLowerCase();
|
|
477
|
-
const api = transport(opts.apiBase, { [opts.tokenHeader ?? "PRIVATE-TOKEN"]: opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
|
|
527
|
+
const api = transport(opts.apiBase, opts.token ? { [opts.tokenHeader ?? "PRIVATE-TOKEN"]: opts.token } : {}, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
|
|
478
528
|
const project = `/projects/${encodeURIComponent(decodeURIComponent(opts.project))}`;
|
|
479
529
|
const pageParams = (page: number) => `per_page=100&page=${page}`;
|
|
480
530
|
const authorOf = (v: Record<string, unknown>): string | undefined => (isObject(v.author) ? str(v.author.username) : undefined);
|
|
@@ -491,7 +541,8 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
491
541
|
if (id === undefined) return undefined;
|
|
492
542
|
const author = authorOf(v);
|
|
493
543
|
const url = str(v.web_url);
|
|
494
|
-
|
|
544
|
+
const created = str(v.created_at);
|
|
545
|
+
return { id, body: str(v.description) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
495
546
|
};
|
|
496
547
|
const notes = `${project}/merge_requests`;
|
|
497
548
|
const t: Threads = {
|
package/src/workspace/decide.ts
CHANGED
|
@@ -445,7 +445,9 @@ export interface AskPointOptions {
|
|
|
445
445
|
* or the lifecycle ledger (#2786). Defaults to the ledger in a steward's
|
|
446
446
|
* turn (`steward` given, or this process a steward's turn), since a steward
|
|
447
447
|
* never writes the checkout, and to the tree otherwise. A question already
|
|
448
|
-
* held on the ledger is rewritten there whatever this says.
|
|
448
|
+
* held on the ledger is rewritten there whatever this says. Outside a
|
|
449
|
+
* steward's turn, `points ask --store ledger` and a gate's `point.store`
|
|
450
|
+
* reach it (#3815), for a checkout that does not outlive the run.
|
|
449
451
|
*/
|
|
450
452
|
store?: "tree" | "ledger";
|
|
451
453
|
/** The date written as asked_on, and answered_on for a table's answer, YYYY-MM-DD. Defaults to today, in UTC. */
|
|
@@ -823,7 +825,10 @@ async function findQuestion(cwd: string, kind: string | undefined, id: string):
|
|
|
823
825
|
if (kinds.length === 0) throw new PointsWriteError("points-undeclared", "no record kind with an answers block is declared: name one with --kind, or declare one in chant.workspace.json");
|
|
824
826
|
for (const k of kinds) {
|
|
825
827
|
const opened = await openAnswers(k, cwd);
|
|
826
|
-
// A question a steward asked is on the ledger (#2786), and people's answer goes back there.
|
|
828
|
+
// A question a steward asked is on the ledger (#2786), and people's answer goes back there. So is
|
|
829
|
+
// one a CI job asked with --store ledger (#3815), from a checkout that is gone: fetch the
|
|
830
|
+
// branch first, so a clone that never ran the job finds the question.
|
|
831
|
+
await refreshLedger(opened.o.loaded.file);
|
|
827
832
|
const read = await readWithLedger(opened.o);
|
|
828
833
|
const target = read.before.find((e) => e.id === id);
|
|
829
834
|
if (target && target.data !== null) return { opened, before: read.before, read, target: target as FoundQuestion["target"] };
|
|
@@ -304,7 +304,7 @@ export async function runWorkspacePoints(ctx: CommandContext): Promise<number> {
|
|
|
304
304
|
if (!("value" in response)) return print(response);
|
|
305
305
|
ask = responseAsk(response.value);
|
|
306
306
|
}
|
|
307
|
-
return print(await askPoint({ cwd, point, inputs: inputs.value, candidates: given, subject: args.subject, kind: args.kind, ask, dryRun: args.dryRun }));
|
|
307
|
+
return print(await askPoint({ cwd, point, inputs: inputs.value, candidates: given, subject: args.subject, kind: args.kind, ask, ...(args.store ? { store: args.store } : {}), dryRun: args.dryRun }));
|
|
308
308
|
}
|
|
309
309
|
if (verb === "answer") {
|
|
310
310
|
const id = args.extraPositional2;
|