@bluewombat/runtime 0.8.0 → 0.10.0
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/README.md +236 -142
- package/SPECS.md +138 -97
- package/dist/cli.js +35 -37
- package/dist/cli.js.map +1 -1
- package/dist/config/agent-shorthand.d.ts +24 -4
- package/dist/config/agent-shorthand.d.ts.map +1 -1
- package/dist/config/agent-shorthand.js +65 -28
- package/dist/config/agent-shorthand.js.map +1 -1
- package/dist/config/defaults.d.ts +0 -2
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +0 -2
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/find-config.d.ts +3 -2
- package/dist/config/find-config.d.ts.map +1 -1
- package/dist/config/find-config.js +4 -4
- package/dist/config/find-config.js.map +1 -1
- package/dist/config/home.d.ts +2 -0
- package/dist/config/home.d.ts.map +1 -1
- package/dist/config/home.js +4 -2
- package/dist/config/home.js.map +1 -1
- package/dist/config/load-config.d.ts +2 -2
- package/dist/config/load-config.d.ts.map +1 -1
- package/dist/config/load-config.js +342 -73
- package/dist/config/load-config.js.map +1 -1
- package/dist/config/parse-args.d.ts +1 -1
- package/dist/config/parse-args.d.ts.map +1 -1
- package/dist/config/parse-args.js +132 -103
- package/dist/config/parse-args.js.map +1 -1
- package/dist/config/types.d.ts +142 -38
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +13 -1
- package/dist/config/types.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/loop/authority.d.ts +41 -5
- package/dist/loop/authority.d.ts.map +1 -1
- package/dist/loop/authority.js +68 -24
- package/dist/loop/authority.js.map +1 -1
- package/dist/loop/context.d.ts +23 -3
- package/dist/loop/context.d.ts.map +1 -1
- package/dist/loop/deliveries.d.ts +16 -16
- package/dist/loop/deliveries.d.ts.map +1 -1
- package/dist/loop/deliveries.js +35 -187
- package/dist/loop/deliveries.js.map +1 -1
- package/dist/loop/describe.d.ts +4 -6
- package/dist/loop/describe.d.ts.map +1 -1
- package/dist/loop/describe.js +2 -2
- package/dist/loop/describe.js.map +1 -1
- package/dist/loop/drive.d.ts +11 -7
- package/dist/loop/drive.d.ts.map +1 -1
- package/dist/loop/drive.js +24 -38
- package/dist/loop/drive.js.map +1 -1
- package/dist/loop/features.d.ts +23 -0
- package/dist/loop/features.d.ts.map +1 -0
- package/dist/loop/features.js +834 -0
- package/dist/loop/features.js.map +1 -0
- package/dist/loop/journal.d.ts +1 -1
- package/dist/loop/journal.js +2 -2
- package/dist/loop/journal.js.map +1 -1
- package/dist/loop/lock.d.ts +1 -1
- package/dist/loop/lock.js +1 -1
- package/dist/loop/open-host.d.ts.map +1 -1
- package/dist/loop/open-host.js +132 -31
- package/dist/loop/open-host.js.map +1 -1
- package/dist/loop/probe.d.ts +1 -1
- package/dist/loop/probe.d.ts.map +1 -1
- package/dist/loop/probe.js +51 -22
- package/dist/loop/probe.js.map +1 -1
- package/dist/loop/process-tree.d.ts +1 -1
- package/dist/loop/process-tree.js +1 -1
- package/dist/loop/report.d.ts +9 -7
- package/dist/loop/report.d.ts.map +1 -1
- package/dist/loop/report.js +49 -106
- package/dist/loop/report.js.map +1 -1
- package/dist/loop/streams.d.ts +5 -5
- package/dist/loop/streams.d.ts.map +1 -1
- package/dist/loop/streams.js +3 -3
- package/dist/loop/streams.js.map +1 -1
- package/dist/loop/tick.d.ts +6 -4
- package/dist/loop/tick.d.ts.map +1 -1
- package/dist/loop/tick.js +50 -78
- package/dist/loop/tick.js.map +1 -1
- package/dist/loop/trace.d.ts +5 -5
- package/dist/loop/trace.d.ts.map +1 -1
- package/dist/loop/trace.js +5 -14
- package/dist/loop/trace.js.map +1 -1
- package/dist/loop/transformers.d.ts +18 -14
- package/dist/loop/transformers.d.ts.map +1 -1
- package/dist/loop/transformers.js +139 -74
- package/dist/loop/transformers.js.map +1 -1
- package/dist/loop/warm.d.ts +3 -3
- package/dist/loop/warm.js +4 -4
- package/dist/loop/warm.js.map +1 -1
- package/dist/loop/work-line.d.ts +1 -1
- package/dist/operator/agent-plugins.d.ts +11 -0
- package/dist/operator/agent-plugins.d.ts.map +1 -1
- package/dist/operator/agent-plugins.js +17 -11
- package/dist/operator/agent-plugins.js.map +1 -1
- package/dist/operator/board.d.ts +3 -8
- package/dist/operator/board.d.ts.map +1 -1
- package/dist/operator/board.js +2 -11
- package/dist/operator/board.js.map +1 -1
- package/dist/operator/cancel.d.ts +2 -2
- package/dist/operator/cancel.js +6 -6
- package/dist/operator/cancel.js.map +1 -1
- package/dist/operator/doctor.d.ts +9 -0
- package/dist/operator/doctor.d.ts.map +1 -1
- package/dist/operator/doctor.js +110 -53
- package/dist/operator/doctor.js.map +1 -1
- package/dist/operator/explain.d.ts +76 -0
- package/dist/operator/explain.d.ts.map +1 -0
- package/dist/operator/explain.js +585 -0
- package/dist/operator/explain.js.map +1 -0
- package/dist/operator/film.d.ts +3 -11
- package/dist/operator/film.d.ts.map +1 -1
- package/dist/operator/film.js +8 -93
- package/dist/operator/film.js.map +1 -1
- package/dist/operator/init.d.ts.map +1 -1
- package/dist/operator/init.js +52 -55
- package/dist/operator/init.js.map +1 -1
- package/dist/operator/live.js +1 -1
- package/dist/operator/live.js.map +1 -1
- package/dist/operator/log.js +12 -6
- package/dist/operator/log.js.map +1 -1
- package/dist/operator/release.d.ts +2 -2
- package/dist/operator/release.js +7 -7
- package/dist/operator/release.js.map +1 -1
- package/dist/operator/run.d.ts +25 -0
- package/dist/operator/run.d.ts.map +1 -0
- package/dist/operator/run.js +70 -0
- package/dist/operator/run.js.map +1 -0
- package/dist/operator/setup.d.ts.map +1 -1
- package/dist/operator/setup.js +22 -16
- package/dist/operator/setup.js.map +1 -1
- package/dist/plugins/manager.d.ts +33 -2
- package/dist/plugins/manager.d.ts.map +1 -1
- package/dist/plugins/manager.js +67 -0
- package/dist/plugins/manager.js.map +1 -1
- package/package.json +15 -16
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathSegment } from "@bluewombat/conductor";
|
|
5
|
+
import { publish, refreshWorkLine } from "./authority.js";
|
|
6
|
+
import { cancelTicket, handleCancel as cancelTicketByKey, handleResume as resumeTicketByKey, } from "./deliveries.js";
|
|
7
|
+
import { driveUntilBlocked } from "./drive.js";
|
|
8
|
+
import { pushReport } from "./report.js";
|
|
9
|
+
/** Where a Ticket is while it still counts against its Feature. */
|
|
10
|
+
const LIVE = new Set([
|
|
11
|
+
"received",
|
|
12
|
+
"running",
|
|
13
|
+
"escalated",
|
|
14
|
+
"integrating",
|
|
15
|
+
"submitted",
|
|
16
|
+
"merging",
|
|
17
|
+
]);
|
|
18
|
+
/** What a Feature says to its tracker: its own Thread, not a Ticket's. */
|
|
19
|
+
const TO = "features";
|
|
20
|
+
/**
|
|
21
|
+
* One Feature delivery: admit it, resume it, or abandon it. Nothing is driven
|
|
22
|
+
* here — the sweep that follows advances every Project that has a Feature.
|
|
23
|
+
*/
|
|
24
|
+
export async function handleFeatureDelivery(input, payload) {
|
|
25
|
+
const adapted = await input.features.adapt(payload);
|
|
26
|
+
switch (adapted.outcome) {
|
|
27
|
+
case "unavailable":
|
|
28
|
+
case "interrupted":
|
|
29
|
+
return { advance: false };
|
|
30
|
+
case "invalid":
|
|
31
|
+
// Said once per intention as written: the report is itself an edit of the
|
|
32
|
+
// issue, and delivers it again.
|
|
33
|
+
input.journal.append({ event: "invalid", key: adapted.key, reason: adapted.invalid.reason });
|
|
34
|
+
await pushReport(input, {
|
|
35
|
+
event: "invalid",
|
|
36
|
+
key: adapted.key,
|
|
37
|
+
project: adapted.project,
|
|
38
|
+
eventId: `${adapted.key}:invalid:${adapted.fingerprint}`,
|
|
39
|
+
fields: { reason: adapted.invalid.reason },
|
|
40
|
+
}, TO);
|
|
41
|
+
return { advance: true };
|
|
42
|
+
case "converted":
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
const feature = adapted.feature;
|
|
46
|
+
// Poll snapshots have no labeled edge: a resume signal on an escalated upsert
|
|
47
|
+
// is the same instruction as an explicit resume intent.
|
|
48
|
+
if (feature.intent === "upsert" && input.features.signalsResume?.(payload) === true) {
|
|
49
|
+
const before = await input.ledger.getFeature(feature.key);
|
|
50
|
+
if (before.ok && before.aggregate.state === "escalated") {
|
|
51
|
+
await resumeFeature(input, feature.key, feature.project);
|
|
52
|
+
return { advance: true };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
switch (feature.intent) {
|
|
56
|
+
case "resume":
|
|
57
|
+
await resumeFeature(input, feature.key, feature.project);
|
|
58
|
+
return { advance: true };
|
|
59
|
+
case "cancel":
|
|
60
|
+
await cancelFeatureFlow(input, feature.key);
|
|
61
|
+
return { advance: true };
|
|
62
|
+
case "upsert":
|
|
63
|
+
await admitFeature(input, feature);
|
|
64
|
+
return { advance: true };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function admitFeature(input, feature) {
|
|
68
|
+
if (feature.intention === undefined) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// Only a Feature that has not opened takes new words. After that every
|
|
72
|
+
// delivery is the tracker echoing a report of ours, or a human talking
|
|
73
|
+
// about work already made: nothing here restarts it.
|
|
74
|
+
const before = await input.ledger.getFeature(feature.key);
|
|
75
|
+
if (before.ok && before.aggregate.state !== "waiting") {
|
|
76
|
+
// The label that leaves the e2e check out is the one thing a human can still change: it is
|
|
77
|
+
// honoured until the Feature is submitted, and read again by the check each time it runs.
|
|
78
|
+
if (before.aggregate.intention.skip_e2e !== feature.skip_e2e) {
|
|
79
|
+
const moved = await input.ledger.setFeatureSkipE2e({
|
|
80
|
+
key: feature.key,
|
|
81
|
+
skip: feature.skip_e2e,
|
|
82
|
+
});
|
|
83
|
+
if (moved.ok) {
|
|
84
|
+
input.journal.append({ event: "skip-e2e", key: feature.key, skip: feature.skip_e2e });
|
|
85
|
+
input.trace(`feature ${feature.key} ${feature.skip_e2e ? "now" : "no longer"} skips the e2e check`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
input.journal.append({ event: "skipped", key: feature.key, state: before.aggregate.state });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const admission = {
|
|
93
|
+
key: feature.key,
|
|
94
|
+
project: feature.project,
|
|
95
|
+
fingerprint: feature.fingerprint,
|
|
96
|
+
priority: feature.priority,
|
|
97
|
+
intention: feature.intention,
|
|
98
|
+
manager: feature.manager,
|
|
99
|
+
external_id: feature.external_id,
|
|
100
|
+
blocked_by: feature.blocked_by,
|
|
101
|
+
merge_when_ready: feature.merge_when_ready,
|
|
102
|
+
...(feature.skip_e2e ? { skip_e2e: true } : {}),
|
|
103
|
+
...(feature.title === undefined ? {} : { title: feature.title }),
|
|
104
|
+
...(feature.source === undefined ? {} : { source: feature.source }),
|
|
105
|
+
};
|
|
106
|
+
await input.ledger.admitFeature(admission);
|
|
107
|
+
}
|
|
108
|
+
async function resumeFeature(input, key, project) {
|
|
109
|
+
const resumed = await input.ledger.resumeFeature(key);
|
|
110
|
+
if (!resumed.ok) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// The signal is an instruction, not a state; left in place it resumes the
|
|
114
|
+
// Feature again on every pass.
|
|
115
|
+
await input.features.clearResume?.(key);
|
|
116
|
+
const got = await input.ledger.getFeature(key);
|
|
117
|
+
const round = got.ok ? got.aggregate.resumes : 0;
|
|
118
|
+
await pushReport(input, {
|
|
119
|
+
event: "resumed",
|
|
120
|
+
key,
|
|
121
|
+
project,
|
|
122
|
+
eventId: `${key}:resumed:${round}`,
|
|
123
|
+
fields: { resume_point: "integrating" },
|
|
124
|
+
}, TO);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Abandon a Feature: its Tickets that are not done are cancelled, its line is
|
|
128
|
+
* dropped, and the Project is free for the next one.
|
|
129
|
+
*/
|
|
130
|
+
export async function cancelFeatureFlow(input, key) {
|
|
131
|
+
const before = await input.ledger.getFeature(key);
|
|
132
|
+
if (!before.ok) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const feature = before.aggregate;
|
|
136
|
+
if (feature.state === "cancelled") {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (feature.state === "done") {
|
|
140
|
+
// A done Feature's issue closing is the tracker agreeing, not a cancel.
|
|
141
|
+
input.journal.append({ event: "skipped", key, state: feature.state });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
for (const summary of await ticketsOf(input, feature)) {
|
|
145
|
+
if (LIVE.has(summary.state)) {
|
|
146
|
+
await cancelTicketByKey({ key: summary.key, project: summary.project }, input);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (feature.line !== undefined) {
|
|
150
|
+
await input.tickets.dropLine?.(feature.line);
|
|
151
|
+
}
|
|
152
|
+
if (input.featureDelivery !== undefined) {
|
|
153
|
+
await rm(featureWorkspace(input.featureDelivery, key), { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
const cancelled = await input.ledger.cancelFeature(key);
|
|
156
|
+
if (!cancelled.ok) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
await pushReport(input, {
|
|
160
|
+
event: "cancelled",
|
|
161
|
+
key,
|
|
162
|
+
project: feature.intention.project,
|
|
163
|
+
eventId: `${key}:cancelled`,
|
|
164
|
+
fields: { state: feature.state },
|
|
165
|
+
}, TO);
|
|
166
|
+
}
|
|
167
|
+
async function ticketsOf(input, feature) {
|
|
168
|
+
const known = new Set(feature.tickets);
|
|
169
|
+
return (await input.ledger.list()).filter((summary) => known.has(summary.key));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Move a Project's Feature along by one step: open the next one that may open,
|
|
173
|
+
* make its Tickets, close its line, or watch for the human who folds it.
|
|
174
|
+
*/
|
|
175
|
+
export async function advanceProject(input, project) {
|
|
176
|
+
let active = await input.ledger.activeFeatureOn(project);
|
|
177
|
+
if (active === undefined) {
|
|
178
|
+
await openNextFeature(input, project);
|
|
179
|
+
active = await input.ledger.activeFeatureOn(project);
|
|
180
|
+
}
|
|
181
|
+
if (active === undefined) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const got = await input.ledger.getFeature(active.key);
|
|
185
|
+
if (!got.ok) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
switch (got.aggregate.state) {
|
|
189
|
+
case "open":
|
|
190
|
+
return await advanceOpenFeature(input, got.aggregate);
|
|
191
|
+
case "submitted":
|
|
192
|
+
await settleFeature(input, got.aggregate);
|
|
193
|
+
return undefined;
|
|
194
|
+
default:
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** What the tracker says of a Ticket beside its words. */
|
|
199
|
+
function marksOf(entry) {
|
|
200
|
+
return { noTests: entry.no_tests, e2e: entry.e2e === true };
|
|
201
|
+
}
|
|
202
|
+
function refOf(feature) {
|
|
203
|
+
const { intention } = feature;
|
|
204
|
+
return {
|
|
205
|
+
key: intention.key,
|
|
206
|
+
external_id: intention.external_id ?? intention.key,
|
|
207
|
+
project: intention.project,
|
|
208
|
+
...(intention.source === undefined ? {} : { source: intention.source }),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** The line a Feature's Tickets land on: `feature/<id>`, as one safe branch name. */
|
|
212
|
+
function lineOf(feature) {
|
|
213
|
+
const id = feature.intention.external_id ?? feature.intention.key;
|
|
214
|
+
return `feature/${id.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "")}`;
|
|
215
|
+
}
|
|
216
|
+
function admissionOf(ticket, feature, among, marks) {
|
|
217
|
+
return {
|
|
218
|
+
key: ticket.key,
|
|
219
|
+
project: ticket.project,
|
|
220
|
+
fingerprint: ticket.fingerprint,
|
|
221
|
+
priority: ticket.priority,
|
|
222
|
+
intention: ticket.intention,
|
|
223
|
+
manager: ticket.manager,
|
|
224
|
+
external_id: ticket.external_id,
|
|
225
|
+
feature: feature.intention.key,
|
|
226
|
+
blocked_by: ticket.blocked_by.filter((other) => among.has(other)),
|
|
227
|
+
...(feature.line === undefined ? {} : { line: feature.line }),
|
|
228
|
+
...(ticket.title === undefined ? {} : { title: ticket.title }),
|
|
229
|
+
...(ticket.source === undefined ? {} : { source: ticket.source }),
|
|
230
|
+
// The Ticket that holds the acceptance suite writes the tests itself: none are written first.
|
|
231
|
+
...(marks.noTests || marks.e2e ? { no_tests: true } : {}),
|
|
232
|
+
...(marks.e2e ? { e2e: true } : {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* What is wrong with a Feature of a Project that proves its Features by an e2e suite: it holds
|
|
237
|
+
* no e2e Ticket and is not marked to skip the check, holds several, or its e2e Ticket may start
|
|
238
|
+
* before the others have landed. Nothing: a Project without the check, or a Feature that skips it.
|
|
239
|
+
*/
|
|
240
|
+
function e2eRefusal(input, feature, live) {
|
|
241
|
+
if (input.options.builder.e2e === undefined || feature.intention.skip_e2e === true) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
const holding = live.filter((entry) => entry.e2e);
|
|
245
|
+
const head = "This Project proves each Feature by an e2e acceptance suite, run once on the whole Feature.";
|
|
246
|
+
if (holding.length === 0) {
|
|
247
|
+
return `${head} The Feature has no e2e Ticket and is not marked skip-test-e2e: add the e2e Ticket (the scenarios agreed with the operator, blocked by every other Ticket), or mark the Feature to skip the check.`;
|
|
248
|
+
}
|
|
249
|
+
if (holding.length > 1) {
|
|
250
|
+
return `${head} The Feature holds ${holding.length} e2e Tickets: it holds one, with all its scenarios.`;
|
|
251
|
+
}
|
|
252
|
+
const only = holding[0];
|
|
253
|
+
const others = live.filter((entry) => !entry.e2e).map((entry) => entry.ticket.key);
|
|
254
|
+
const missing = others.filter((key) => !only?.ticket.blocked_by.includes(key));
|
|
255
|
+
if (only !== undefined && missing.length > 0) {
|
|
256
|
+
return `${head} The e2e Ticket ${only.ticket.key} must be blocked by every other Ticket of the Feature, so that it lands last. It is not blocked by: ${missing.join(", ")}.`;
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
/** Whether a Feature still holds an e2e Ticket that was not cancelled. */
|
|
261
|
+
async function holdsLiveE2eTicket(input, feature) {
|
|
262
|
+
for (const key of feature.tickets) {
|
|
263
|
+
const got = await input.ledger.get(key);
|
|
264
|
+
if (got.ok && got.aggregate.intention.e2e === true && got.aggregate.state !== "cancelled") {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Open the next Feature that may: waiting, nothing blocking it, and every one
|
|
272
|
+
* of its Tickets written. Until then it waits and nothing is made.
|
|
273
|
+
*/
|
|
274
|
+
async function openNextFeature(input, project) {
|
|
275
|
+
const next = await input.ledger.nextFeature(project);
|
|
276
|
+
if (next === undefined) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const got = await input.ledger.getFeature(next.key);
|
|
280
|
+
if (!got.ok) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const feature = got.aggregate;
|
|
284
|
+
const listed = await input.tickets.tickets(refOf(feature));
|
|
285
|
+
if (listed.outcome !== "listed") {
|
|
286
|
+
input.journal.append({ event: "tickets-unavailable", key: feature.intention.key });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const live = listed.tickets.filter((entry) => entry.open);
|
|
290
|
+
if (live.length === 0 || !live.every((entry) => entry.task_ready)) {
|
|
291
|
+
input.journal.append({
|
|
292
|
+
event: "waiting",
|
|
293
|
+
key: feature.intention.key,
|
|
294
|
+
reason: live.length === 0 ? "it has no Ticket yet" : "a Ticket is not ready yet",
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const refusal = e2eRefusal(input, feature, live);
|
|
299
|
+
if (refusal !== undefined) {
|
|
300
|
+
input.journal.append({ event: "invalid", key: feature.intention.key, reason: refusal });
|
|
301
|
+
await pushReport(input, {
|
|
302
|
+
event: "invalid",
|
|
303
|
+
key: feature.intention.key,
|
|
304
|
+
project,
|
|
305
|
+
eventId: `${feature.intention.key}:invalid:e2e:${refusal}`,
|
|
306
|
+
fields: { reason: refusal },
|
|
307
|
+
}, TO);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const target = input.workLine.target;
|
|
311
|
+
let line;
|
|
312
|
+
if (input.authority !== undefined &&
|
|
313
|
+
input.tickets.openLine !== undefined &&
|
|
314
|
+
target !== undefined) {
|
|
315
|
+
line = lineOf(feature);
|
|
316
|
+
const made = await input.tickets.openLine({ line, from: target });
|
|
317
|
+
if (made.outcome === "unavailable") {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (made.outcome === "refused") {
|
|
321
|
+
input.journal.append({
|
|
322
|
+
event: "line-refused",
|
|
323
|
+
key: feature.intention.key,
|
|
324
|
+
reason: made.reason,
|
|
325
|
+
});
|
|
326
|
+
await pushReport(input, {
|
|
327
|
+
event: "progress",
|
|
328
|
+
key: feature.intention.key,
|
|
329
|
+
project,
|
|
330
|
+
eventId: `${feature.intention.key}:line-refused:${made.reason}`,
|
|
331
|
+
fields: { summary: `Its line could not be opened: ${made.reason}` },
|
|
332
|
+
}, TO);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const among = new Set(live.map((entry) => entry.ticket.key));
|
|
337
|
+
const opened = await input.ledger.openFeature({
|
|
338
|
+
key: feature.intention.key,
|
|
339
|
+
...(line === undefined ? {} : { line }),
|
|
340
|
+
tickets: [...among],
|
|
341
|
+
});
|
|
342
|
+
if (!opened.ok) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const withLine = { ...feature, ...(line === undefined ? {} : { line }) };
|
|
346
|
+
for (const entry of live) {
|
|
347
|
+
await input.ledger.admit(admissionOf(entry.ticket, withLine, among, marksOf(entry)));
|
|
348
|
+
}
|
|
349
|
+
input.journal.append({
|
|
350
|
+
event: "feature-opened",
|
|
351
|
+
key: feature.intention.key,
|
|
352
|
+
...(line === undefined ? {} : { line }),
|
|
353
|
+
tickets: live.length,
|
|
354
|
+
});
|
|
355
|
+
input.trace(`feature ${feature.intention.key} opened with ${live.length} ticket(s)`);
|
|
356
|
+
await pushReport(input, {
|
|
357
|
+
event: "accepted",
|
|
358
|
+
key: feature.intention.key,
|
|
359
|
+
project,
|
|
360
|
+
eventId: `${feature.intention.key}:accepted:${feature.intention.fingerprint}`,
|
|
361
|
+
fields: { priority: feature.intention.priority },
|
|
362
|
+
}, TO);
|
|
363
|
+
for (const entry of live) {
|
|
364
|
+
await pushReport(input, {
|
|
365
|
+
event: "accepted",
|
|
366
|
+
key: entry.ticket.key,
|
|
367
|
+
project,
|
|
368
|
+
eventId: `${entry.ticket.key}:accepted:${entry.ticket.fingerprint}`,
|
|
369
|
+
fields: { priority: entry.ticket.priority },
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Bring the work line copy onto the line the next unit is made from. Without an
|
|
375
|
+
* Authority the work line is the operator's and nothing is fetched.
|
|
376
|
+
*/
|
|
377
|
+
async function refreshOnto(input, branch) {
|
|
378
|
+
if (input.authority === undefined || branch === undefined) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
const refreshed = await refreshWorkLine({
|
|
382
|
+
workLineStable: input.workLine.stable,
|
|
383
|
+
workLineTarget: branch,
|
|
384
|
+
// A Feature's line that has not moved is brought up to the final branch
|
|
385
|
+
// first, so its Tickets read the code as it now is.
|
|
386
|
+
workLineBase: branch === input.workLine.target ? undefined : input.workLine.target,
|
|
387
|
+
refreshArgv: input.options.authority?.refreshArgv ?? [],
|
|
388
|
+
timeoutMs: input.options.timeoutMs,
|
|
389
|
+
env: input.workLine.env,
|
|
390
|
+
});
|
|
391
|
+
if (!refreshed.ok) {
|
|
392
|
+
input.journal.append({
|
|
393
|
+
event: "refresh",
|
|
394
|
+
outcome: "failed",
|
|
395
|
+
target: branch,
|
|
396
|
+
...(refreshed.detail === undefined ? {} : { detail: refreshed.detail }),
|
|
397
|
+
});
|
|
398
|
+
input.trace(`work line cannot fast-forward onto ${branch}, so nothing started. ${refreshed.detail ?? ""}`);
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
input.journal.append({ event: "refresh", outcome: "ok", target: branch });
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* What the tracker says of the Tickets of an open Feature: an edit, a human's
|
|
406
|
+
* resume, a Ticket closed under our feet. A Ticket that joined after the
|
|
407
|
+
* Feature opened is not part of it.
|
|
408
|
+
*/
|
|
409
|
+
async function syncTickets(input, feature) {
|
|
410
|
+
const listed = await input.tickets.tickets(refOf(feature));
|
|
411
|
+
if (listed.outcome !== "listed") {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const known = new Set(feature.tickets);
|
|
415
|
+
for (const entry of listed.tickets) {
|
|
416
|
+
if (!known.has(entry.ticket.key)) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
await syncTicket(input, feature, entry, known);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async function syncTicket(input, feature, entry, known) {
|
|
423
|
+
const { ticket } = entry;
|
|
424
|
+
const current = await input.ledger.get(ticket.key);
|
|
425
|
+
if (!current.ok) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const state = current.aggregate.state;
|
|
429
|
+
if (!entry.open) {
|
|
430
|
+
if (LIVE.has(state)) {
|
|
431
|
+
await cancelTicketByKey({ key: ticket.key, project: ticket.project }, input);
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// Only a Ticket that has not started takes the new words: the tracker's
|
|
436
|
+
// fingerprint moves with every comment, and a unit in flight is not rewritten.
|
|
437
|
+
// The label that says no tests are written first is not in the words, so it moves no
|
|
438
|
+
// fingerprint: it is compared on its own.
|
|
439
|
+
const marks = marksOf(entry);
|
|
440
|
+
const flagged = current.aggregate.intention.no_tests === true;
|
|
441
|
+
const holdsE2e = current.aggregate.intention.e2e === true;
|
|
442
|
+
if (state === "received" &&
|
|
443
|
+
(current.aggregate.intention.fingerprint !== ticket.fingerprint ||
|
|
444
|
+
flagged !== (marks.noTests || marks.e2e) ||
|
|
445
|
+
holdsE2e !== marks.e2e)) {
|
|
446
|
+
await input.ledger.admit(admissionOf(ticket, feature, known, marks));
|
|
447
|
+
}
|
|
448
|
+
// A Feature that is not put through the e2e check has no use of the Ticket that holds it.
|
|
449
|
+
if (feature.intention.skip_e2e === true && marks.e2e && LIVE.has(state)) {
|
|
450
|
+
input.journal.append({
|
|
451
|
+
event: "e2e-ticket-cancelled",
|
|
452
|
+
key: ticket.key,
|
|
453
|
+
feature: feature.intention.key,
|
|
454
|
+
});
|
|
455
|
+
input.trace(`feature ${feature.intention.key} skips the e2e check: ticket ${ticket.key} is cancelled`);
|
|
456
|
+
await cancelTicketByKey({ key: ticket.key, project: ticket.project }, input);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (entry.resume && state === "escalated") {
|
|
460
|
+
await resumeTicketByKey({ key: ticket.key, project: ticket.project }, input);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async function advanceOpenFeature(input, feature) {
|
|
464
|
+
const project = feature.intention.project;
|
|
465
|
+
await syncTickets(input, feature);
|
|
466
|
+
const limit = input.options.ticket.maxParallel ?? 1;
|
|
467
|
+
// A Ticket waiting for a person holds its place. When every place is held
|
|
468
|
+
// that way, nothing can move until someone answers.
|
|
469
|
+
const holding = await input.ledger.activeTickets(project);
|
|
470
|
+
if (holding.length >= limit && holding.every((summary) => summary.state === "escalated")) {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
// Nothing is taken if the line cannot be reached. Conductor refreshes it again
|
|
474
|
+
// for each Ticket it cuts and aligns, which is what keeps a Ticket current
|
|
475
|
+
// while others land; this is the check that starts nothing at all.
|
|
476
|
+
if (!(await refreshOnto(input, feature.line ?? input.workLine.target))) {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
const run = await driveTickets(input, feature, project, limit);
|
|
480
|
+
const tickets = await ticketsOf(input, feature);
|
|
481
|
+
await reportLanded(input, feature, tickets);
|
|
482
|
+
const settled = tickets.every((summary) => summary.state === "done" || summary.state === "cancelled");
|
|
483
|
+
if (settled && tickets.length > 0) {
|
|
484
|
+
await closeFeature(input, feature, tickets);
|
|
485
|
+
return run;
|
|
486
|
+
}
|
|
487
|
+
if (run?.outcome === "idle" && tickets.some((summary) => summary.state === "received")) {
|
|
488
|
+
await escalateFeature(input, feature, "No Ticket can start: each waits on a Ticket that is not done.");
|
|
489
|
+
}
|
|
490
|
+
return run;
|
|
491
|
+
}
|
|
492
|
+
/** Where a Ticket is while Host has something to drive it through. */
|
|
493
|
+
const DRIVEN = new Set([
|
|
494
|
+
"running",
|
|
495
|
+
"integrating",
|
|
496
|
+
"submitted",
|
|
497
|
+
"merging",
|
|
498
|
+
]);
|
|
499
|
+
/**
|
|
500
|
+
* Work the Tickets of an open Feature, up to `limit` at a time.
|
|
501
|
+
*
|
|
502
|
+
* Each place is a worker: it drives a Ticket the Project already holds, else
|
|
503
|
+
* takes the next one that may start, and goes on to another when its own is
|
|
504
|
+
* done or is only waiting for the outside. A Ticket waiting on a check holds a
|
|
505
|
+
* place but not a worker, so the others do not wait with it. With a limit of 1
|
|
506
|
+
* this is one Ticket after the other.
|
|
507
|
+
*/
|
|
508
|
+
async function driveTickets(input, feature, project, limit) {
|
|
509
|
+
// Once, before anything runs at the same time: it clears what a dead process
|
|
510
|
+
// left, which must not be done under a Ticket that is being driven.
|
|
511
|
+
await input.conductor.reconcile(project);
|
|
512
|
+
const interrupted = () => input.options.interruptFlag?.interrupted === true;
|
|
513
|
+
const queue = (await ticketsOf(input, feature))
|
|
514
|
+
.filter((summary) => DRIVEN.has(summary.state))
|
|
515
|
+
.map((summary) => summary.key);
|
|
516
|
+
let last;
|
|
517
|
+
const worker = async () => {
|
|
518
|
+
while (!interrupted()) {
|
|
519
|
+
const key = queue.shift() ?? (await input.conductor.claimNext(project))?.key;
|
|
520
|
+
if (key === undefined) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const run = await driveUntilBlocked(input, project, key, true);
|
|
524
|
+
last = run;
|
|
525
|
+
// A refusal is about the run, not the Ticket: the next one would meet it too.
|
|
526
|
+
if (run.outcome === "refused") {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
await Promise.all(Array.from({ length: Math.max(1, limit) }, worker));
|
|
532
|
+
if (last === undefined && (await input.ledger.activeTickets(project)).length === 0) {
|
|
533
|
+
const waiting = (await ticketsOf(input, feature)).some((s) => s.state === "received");
|
|
534
|
+
// Nothing held and nothing could start: what remains waits on what cannot finish.
|
|
535
|
+
return waiting ? { outcome: "idle" } : undefined;
|
|
536
|
+
}
|
|
537
|
+
return last;
|
|
538
|
+
}
|
|
539
|
+
/** One `progress` per Ticket that landed, in the words the tracker already has. */
|
|
540
|
+
async function reportLanded(input, feature, tickets) {
|
|
541
|
+
const total = feature.tickets.length;
|
|
542
|
+
const done = tickets.filter((summary) => summary.state === "done");
|
|
543
|
+
for (const [at, summary] of done.entries()) {
|
|
544
|
+
const got = await input.ledger.get(summary.key);
|
|
545
|
+
const title = got.ok ? (got.aggregate.intention.title ?? summary.key) : summary.key;
|
|
546
|
+
await pushReport(input, {
|
|
547
|
+
event: "progress",
|
|
548
|
+
key: feature.intention.key,
|
|
549
|
+
project: feature.intention.project,
|
|
550
|
+
eventId: `${feature.intention.key}:landed:${summary.key}`,
|
|
551
|
+
fields: { summary: `Landed ${at + 1} of ${total}: ${title}` },
|
|
552
|
+
}, TO);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
async function escalateFeature(input, feature, report) {
|
|
556
|
+
const escalated = await input.ledger.escalateFeature({ key: feature.intention.key, report });
|
|
557
|
+
if (!escalated.ok) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const got = await input.ledger.getFeature(feature.intention.key);
|
|
561
|
+
const round = got.ok ? got.aggregate.resumes : 0;
|
|
562
|
+
input.trace(`feature ${feature.intention.key} escalated: ${report}`);
|
|
563
|
+
await pushReport(input, {
|
|
564
|
+
event: "escalated",
|
|
565
|
+
key: feature.intention.key,
|
|
566
|
+
project: feature.intention.project,
|
|
567
|
+
eventId: `${feature.intention.key}:escalated:${round}`,
|
|
568
|
+
fields: { reason: report, stage: "submitting" },
|
|
569
|
+
}, TO);
|
|
570
|
+
}
|
|
571
|
+
/** Where a Feature's own work is done: apart from every Ticket's workspace, which Host clears of strays. */
|
|
572
|
+
function featureWorkspace(delivery, key) {
|
|
573
|
+
return join(delivery.workspaces, pathSegment(key));
|
|
574
|
+
}
|
|
575
|
+
/** Where the Feature's work is, and where it goes: the Feature stands where a Ticket stood, the final line where the Feature's was. */
|
|
576
|
+
function featureUnit(input, delivery, feature) {
|
|
577
|
+
return {
|
|
578
|
+
key: feature.intention.key,
|
|
579
|
+
intention: feature.intention.intention,
|
|
580
|
+
title: feature.intention.title,
|
|
581
|
+
noun: "feature",
|
|
582
|
+
workspace: featureWorkspace(delivery, feature.intention.key),
|
|
583
|
+
// The Gates read the published branch, and the copy of the Feature's line is on it.
|
|
584
|
+
judgeWorkspace: input.workLine.stable,
|
|
585
|
+
target: input.workLine.target ?? "",
|
|
586
|
+
line: delivery.targetStable,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** What a Feature's delivery reads of it, and how the Feature keeps what the delivery decides. */
|
|
590
|
+
function featureStore(input, feature) {
|
|
591
|
+
const key = feature.intention.key;
|
|
592
|
+
const failed = (result) => result.ok ? undefined : { outcome: "refused", code: "illegal-transition" };
|
|
593
|
+
return {
|
|
594
|
+
async read() {
|
|
595
|
+
const got = await input.ledger.getFeature(key);
|
|
596
|
+
if (!got.ok) {
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
const aggregate = got.aggregate;
|
|
600
|
+
const parked = aggregate.parked_refusal;
|
|
601
|
+
const report = parked?.report ?? aggregate.submission?.last_report;
|
|
602
|
+
const refusedBy = parked?.refused_by ?? aggregate.submission?.last_refused_by;
|
|
603
|
+
return {
|
|
604
|
+
sentBack: report === undefined ? undefined : { report, refusedBy },
|
|
605
|
+
spent: (aggregate.submission?.refusals ?? 0) +
|
|
606
|
+
(aggregate.parked_refusals ?? 0) +
|
|
607
|
+
(aggregate.submission?.realigns ?? 0),
|
|
608
|
+
submission: aggregate.submission === undefined
|
|
609
|
+
? undefined
|
|
610
|
+
: { reference: aggregate.submission.reference },
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
async freeze(frozen) {
|
|
614
|
+
await escalateFeature(input, feature, frozen.report ?? "The line was stopped, with no reason given.");
|
|
615
|
+
return { outcome: "escalated", key };
|
|
616
|
+
},
|
|
617
|
+
async park(why) {
|
|
618
|
+
return failed(await input.ledger.recordFeatureParkedRefusal({
|
|
619
|
+
key,
|
|
620
|
+
report: why.report,
|
|
621
|
+
...(why.refusedBy === undefined ? {} : { refusedBy: why.refusedBy }),
|
|
622
|
+
}));
|
|
623
|
+
},
|
|
624
|
+
async unpark() {
|
|
625
|
+
return failed(await input.ledger.clearFeatureParkedRefusal(key));
|
|
626
|
+
},
|
|
627
|
+
async refuse(why) {
|
|
628
|
+
return failed(await input.ledger.recordFeatureRefusal({
|
|
629
|
+
key,
|
|
630
|
+
report: why.report,
|
|
631
|
+
...(why.refusedBy === undefined ? {} : { refusedBy: why.refusedBy }),
|
|
632
|
+
}));
|
|
633
|
+
},
|
|
634
|
+
async realign() {
|
|
635
|
+
return failed(await input.ledger.recordFeatureRealign(key));
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Every Ticket has landed: the line is brought up to date with the one it is
|
|
641
|
+
* folded into, checked, and put in front of the Authority. With none, the work
|
|
642
|
+
* is already in the work line and the Feature is done.
|
|
643
|
+
*
|
|
644
|
+
* It is delivered the way a Ticket is — see `openDelivery` — with the final line
|
|
645
|
+
* merged into the Feature's work, its own checks run on the result, and a fix
|
|
646
|
+
* when something refuses it. Only where the work is offered differs: the line
|
|
647
|
+
* is pushed under its own name rather than a name made for it.
|
|
648
|
+
*/
|
|
649
|
+
async function closeFeature(input, feature, tickets) {
|
|
650
|
+
const key = feature.intention.key;
|
|
651
|
+
const project = feature.intention.project;
|
|
652
|
+
if (!tickets.some((summary) => summary.state === "done")) {
|
|
653
|
+
await escalateFeature(input, feature, "Every Ticket was abandoned, so nothing was made.");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (input.options.builder.e2e !== undefined &&
|
|
657
|
+
feature.intention.skip_e2e !== true &&
|
|
658
|
+
!(await holdsLiveE2eTicket(input, feature))) {
|
|
659
|
+
await escalateFeature(input, feature, "This Feature is not marked skip-test-e2e and holds no e2e Ticket: it was cancelled while the Feature skipped the check, and the mark was taken off since. Mark the Feature skip-test-e2e again to leave the check out.");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (input.authority === undefined || feature.line === undefined) {
|
|
663
|
+
await input.ledger.markFeatureSubmitted({ key, reference: "local" });
|
|
664
|
+
await input.ledger.markFeatureDone(key);
|
|
665
|
+
await reportDone(input, feature, undefined, undefined);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const delivery = input.featureDelivery;
|
|
669
|
+
const target = input.workLine.target;
|
|
670
|
+
const publishArgv = input.options.authority?.publishArgv;
|
|
671
|
+
if (delivery === undefined || target === undefined || publishArgv === undefined) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const unit = featureUnit(input, delivery, feature);
|
|
675
|
+
const clean = async () => {
|
|
676
|
+
await rm(unit.workspace, { recursive: true, force: true });
|
|
677
|
+
};
|
|
678
|
+
// The Feature's work is cut from its line the way a Ticket's is, once; a
|
|
679
|
+
// pass that sent it to the fix finds it where it was left.
|
|
680
|
+
if (!existsSync(unit.workspace)) {
|
|
681
|
+
if (!(await refreshOnto(input, feature.line))) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const isolated = await input.transformers.isolate({
|
|
685
|
+
id: `${key}:close`,
|
|
686
|
+
context: key,
|
|
687
|
+
parent: input.workLine.stable,
|
|
688
|
+
child: unit.workspace,
|
|
689
|
+
durationMs: input.options.timeoutMs,
|
|
690
|
+
});
|
|
691
|
+
if (isolated.outcome !== "isolated") {
|
|
692
|
+
// Nothing was spent: the next pass tries again.
|
|
693
|
+
await clean();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
// The checks run here, so it is prepared the way a Ticket's workspace is.
|
|
697
|
+
const warmed = await delivery.warm?.prepare({
|
|
698
|
+
workspace: unit.workspace,
|
|
699
|
+
durationMs: delivery.warm.durationMs,
|
|
700
|
+
});
|
|
701
|
+
if (warmed !== undefined && warmed.outcome !== "warmed") {
|
|
702
|
+
await clean();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const prepared = await delivery.delivery.prepare(unit, featureStore(input, feature));
|
|
707
|
+
if (prepared !== "ready") {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// Offer it. What was made in the workspace goes back into the Feature's line
|
|
711
|
+
// and the line is pushed under its own name, which is where the Authority
|
|
712
|
+
// reads it and where its Submission is.
|
|
713
|
+
const folded = await input.transformers.integrate({
|
|
714
|
+
id: `${key}:close`,
|
|
715
|
+
context: key,
|
|
716
|
+
parent: input.workLine.stable,
|
|
717
|
+
child: unit.workspace,
|
|
718
|
+
durationMs: input.options.timeoutMs,
|
|
719
|
+
subject: `Merge ${target} into ${feature.line}`,
|
|
720
|
+
mergeSubject: `Merge ${target} into ${feature.line}`,
|
|
721
|
+
});
|
|
722
|
+
if (folded.outcome === "interrupted") {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (folded.outcome !== "integrated") {
|
|
726
|
+
await escalateFeature(input, feature, "The Feature's work could not be folded into its line.");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const published = await publish({
|
|
730
|
+
argv: publishArgv,
|
|
731
|
+
cwd: input.workLine.stable,
|
|
732
|
+
id: key,
|
|
733
|
+
ref: feature.line,
|
|
734
|
+
target,
|
|
735
|
+
timeoutMs: input.options.timeoutMs,
|
|
736
|
+
env: input.workLine.env,
|
|
737
|
+
});
|
|
738
|
+
if (!published.ok) {
|
|
739
|
+
await escalateFeature(input, feature, published.reason);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const submitted = await input.authority.submit({
|
|
743
|
+
key,
|
|
744
|
+
project,
|
|
745
|
+
ref: key,
|
|
746
|
+
branch: feature.line,
|
|
747
|
+
target,
|
|
748
|
+
workspace: input.workLine.stable,
|
|
749
|
+
...(feature.intention.title === undefined ? {} : { title: feature.intention.title }),
|
|
750
|
+
intention: feature.intention.intention,
|
|
751
|
+
});
|
|
752
|
+
if (submitted.outcome === "unavailable") {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (submitted.outcome === "refused") {
|
|
756
|
+
await escalateFeature(input, feature, submitted.reason);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
await input.ledger.markFeatureSubmitted({ key, reference: submitted.reference });
|
|
760
|
+
await clean();
|
|
761
|
+
await pushReport(input, {
|
|
762
|
+
event: "submitted",
|
|
763
|
+
key,
|
|
764
|
+
project,
|
|
765
|
+
eventId: `${key}:submitted:${submitted.reference}`,
|
|
766
|
+
fields: { reference: submitted.reference },
|
|
767
|
+
}, TO);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* The line is with the Authority. If the Feature merges by itself, its gates
|
|
771
|
+
* decide, and a line the target moved on from goes back to be brought up to
|
|
772
|
+
* date, exactly as a Ticket does; otherwise a human folds it and the system
|
|
773
|
+
* waits, starting nothing.
|
|
774
|
+
*/
|
|
775
|
+
async function settleFeature(input, feature) {
|
|
776
|
+
const submission = feature.submission;
|
|
777
|
+
if (submission === undefined || input.authority === undefined || feature.line === undefined) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const key = feature.intention.key;
|
|
781
|
+
if (!feature.intention.merge_when_ready) {
|
|
782
|
+
const settled = (await input.tickets.settled?.({ reference: submission.reference })) ?? "unavailable";
|
|
783
|
+
if (settled === "folded") {
|
|
784
|
+
await input.ledger.markFeatureDone(key);
|
|
785
|
+
await reportDone(input, feature, submission.reference, undefined);
|
|
786
|
+
}
|
|
787
|
+
else if (settled === "closed") {
|
|
788
|
+
await escalateFeature(input, feature, `The Submission ${submission.reference} was closed without being folded.`);
|
|
789
|
+
}
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const delivery = input.featureDelivery;
|
|
793
|
+
if (delivery === undefined || !(await refreshOnto(input, feature.line))) {
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const settled = await delivery.delivery.settle(featureUnit(input, delivery, feature), featureStore(input, feature));
|
|
797
|
+
if (!("folded" in settled)) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
await input.ledger.markFeatureDone(key, { reference: settled.reference });
|
|
801
|
+
await reportDone(input, feature, submission.reference, settled.reference);
|
|
802
|
+
}
|
|
803
|
+
async function reportDone(input, feature, reference, integrationReference) {
|
|
804
|
+
const key = feature.intention.key;
|
|
805
|
+
input.trace(`feature ${key} done`);
|
|
806
|
+
await pushReport(input, {
|
|
807
|
+
event: "done",
|
|
808
|
+
key,
|
|
809
|
+
project: feature.intention.project,
|
|
810
|
+
eventId: `${key}:done`,
|
|
811
|
+
fields: {
|
|
812
|
+
...(reference === undefined || reference === "local" ? {} : { reference }),
|
|
813
|
+
...(integrationReference === undefined
|
|
814
|
+
? {}
|
|
815
|
+
: { integration_reference: integrationReference }),
|
|
816
|
+
},
|
|
817
|
+
}, TO);
|
|
818
|
+
}
|
|
819
|
+
/** Abandon a Feature or a Ticket by key: whichever the ledger knows. */
|
|
820
|
+
export async function cancelKey(input, key) {
|
|
821
|
+
const feature = await input.ledger.getFeature(key);
|
|
822
|
+
if (!feature.ok) {
|
|
823
|
+
return await cancelTicket(input, key);
|
|
824
|
+
}
|
|
825
|
+
if (feature.aggregate.state === "cancelled") {
|
|
826
|
+
return { ok: false, code: "already-cancelled" };
|
|
827
|
+
}
|
|
828
|
+
if (feature.aggregate.state === "done") {
|
|
829
|
+
return { ok: false, code: "done" };
|
|
830
|
+
}
|
|
831
|
+
await cancelFeatureFlow({ ...input, reported: [] }, key);
|
|
832
|
+
return { ok: true };
|
|
833
|
+
}
|
|
834
|
+
//# sourceMappingURL=features.js.map
|