omp-conductor 0.17.0 → 0.18.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/REFERENCE.md +12 -8
- package/package.json +1 -1
- package/schema/config.schema.json +40 -1
- package/src/admission.ts +263 -44
- package/src/ask.ts +39 -3
- package/src/availability.ts +27 -1
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +38 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/arm.ts +6 -3
- package/src/commands/message.ts +32 -4
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +53 -0
- package/src/config.ts +97 -1
- package/src/daemon.ts +1479 -1483
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +71 -15
- package/src/fleet.ts +189 -34
- package/src/gitops.ts +103 -24
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +43 -7
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +430 -162
- package/src/release-policy.ts +177 -5
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +1257 -78
- package/src/setup.ts +153 -6
- package/src/status-render.ts +36 -4
- package/src/store.ts +411 -17
- package/src/tracker/github.ts +607 -12
- package/src/types.ts +331 -5
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +270 -13
- package/src/worker.ts +239 -6
- package/src/worktree.ts +115 -8
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/decisions.ts
CHANGED
|
@@ -161,6 +161,16 @@ export async function probeRateLimitReset(runGh: RateLimitGh = ghRateLimit): Pro
|
|
|
161
161
|
* pass. A condition that could not be checked is simply not met this time, and
|
|
162
162
|
* the next tick asks again for free.
|
|
163
163
|
*
|
|
164
|
+
* A watch (#459) whose PR condition can no longer be observed — the PR merged
|
|
165
|
+
* or closed before the requested condition was seen — is withdrawn in this same
|
|
166
|
+
* pass rather than left rendering `condition:pending` forever: the mediated
|
|
167
|
+
* surfaces for checks and mergeability do not answer for a settled PR, so the
|
|
168
|
+
* row could only ever stay unmet, and a watch has deliberately no seven-day
|
|
169
|
+
* expiry to close it. The withdrawal is the same durable `state=withdrawn`
|
|
170
|
+
* resolution an operator's `watch withdraw` writes, so the audit trail of what
|
|
171
|
+
* was waiting and why it stopped survives. Operator questions are untouched:
|
|
172
|
+
* they answer to a human and expire on the seven-day clock.
|
|
173
|
+
*
|
|
164
174
|
* Returns the rows that just became met, so the caller can log what changed
|
|
165
175
|
* rather than a count.
|
|
166
176
|
*/
|
|
@@ -179,12 +189,29 @@ export async function evaluateDecisionConditions(
|
|
|
179
189
|
let satisfied = false;
|
|
180
190
|
try {
|
|
181
191
|
if (condition.kind === "pr-merged") {
|
|
182
|
-
|
|
192
|
+
const state = await tracker.prState(condition.url);
|
|
193
|
+
if (state === "merged") {
|
|
194
|
+
satisfied = true;
|
|
195
|
+
} else if (decision.kind === "watch" && state === "closed") {
|
|
196
|
+
// The PR closed without merging: `pr-merged` can never be observed.
|
|
197
|
+
store.resolveDecision(decision.id, "withdrawn", "PR closed without merging", now());
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
183
200
|
} else if (condition.kind === "issue-closed") {
|
|
184
201
|
satisfied = (await tracker.issueState(condition.issue)) === "closed";
|
|
185
202
|
} else if (condition.kind === "npm-version") {
|
|
186
203
|
satisfied = await probes.npm(condition.spec);
|
|
187
204
|
} else if (condition.kind === "pr-checks-green") {
|
|
205
|
+
// A watch whose PR settled before its checks were seen green cannot
|
|
206
|
+
// ever be met: the checks surface does not answer for a merged or
|
|
207
|
+
// closed PR, so the row would render `pending` forever (#664).
|
|
208
|
+
if (decision.kind === "watch") {
|
|
209
|
+
const state = await tracker.prState(condition.url);
|
|
210
|
+
if (state === "merged" || state === "closed") {
|
|
211
|
+
store.resolveDecision(decision.id, "withdrawn", `PR ${state} before its checks were seen`, now());
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
188
215
|
// The same conclusion values the daemon's failure classifier treats as
|
|
189
216
|
// a green verdict (`success` / `neutral`, lowercased): a non-empty list
|
|
190
217
|
// in which every check is terminally successful and none is failing or
|
|
@@ -193,6 +220,16 @@ export async function evaluateDecisionConditions(
|
|
|
193
220
|
satisfied =
|
|
194
221
|
checks.length > 0 && checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true);
|
|
195
222
|
} else if (condition.kind === "pr-mergeable") {
|
|
223
|
+
// A watch whose PR settled before it was seen mergeable is the same
|
|
224
|
+
// unobservable case as checks: the mergeability literal exists only
|
|
225
|
+
// while the PR is open (#664).
|
|
226
|
+
if (decision.kind === "watch") {
|
|
227
|
+
const state = await tracker.prState(condition.url);
|
|
228
|
+
if (state === "merged" || state === "closed") {
|
|
229
|
+
store.resolveDecision(decision.id, "withdrawn", `PR ${state} before it was seen mergeable`, now());
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
196
233
|
// `clean` is the tracker's "this PR can merge" literal; `unknown` and a
|
|
197
234
|
// conflict are both unsatisfied (#189).
|
|
198
235
|
satisfied = (await tracker.mergeable(condition.url)) === "clean";
|
|
@@ -242,7 +279,9 @@ function age(since: number, now: number): string {
|
|
|
242
279
|
* watch heading with no instruction to resolve it, so a fleet at rest behind
|
|
243
280
|
* GitHub's checks is never mistaken for a fleet that is waiting on its
|
|
244
281
|
* operator. A met watch still surfaces to the orchestrator with its note and
|
|
245
|
-
* the same `[CONDITION MET]` flag a met question gets.
|
|
282
|
+
* the same `[CONDITION MET]` flag a met question gets. Every watch line also
|
|
283
|
+
* names the command that closes it (`watch withdraw <id>`), because the
|
|
284
|
+
* surface that shows a watch is where the reader learns how to end one (#664).
|
|
246
285
|
*/
|
|
247
286
|
export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date.now()): string {
|
|
248
287
|
const questions = open.filter((d) => d.kind !== "watch");
|
|
@@ -261,14 +300,20 @@ export function formatDecisionDigest(open: readonly DecisionRecord[], now = Date
|
|
|
261
300
|
`Watches (${watches.length}) — conditions the orchestrator set for itself; no operator action needed:`,
|
|
262
301
|
);
|
|
263
302
|
for (const d of watches) {
|
|
264
|
-
|
|
303
|
+
// Each line names the verb that closes it, so the orchestrator reading a
|
|
304
|
+
// digest does not have to know the decisions vocabulary (#664).
|
|
305
|
+
lines.push(decisionLine(d, now, `watch withdraw ${d.id}`));
|
|
265
306
|
}
|
|
266
307
|
}
|
|
267
308
|
return lines.join("\n");
|
|
268
309
|
}
|
|
269
310
|
|
|
270
|
-
/**
|
|
271
|
-
|
|
311
|
+
/**
|
|
312
|
+
* One digest row: id, age, what it blocks, the met flag, and the note.
|
|
313
|
+
* `closing` names the command that ends the row, rendered after the note.
|
|
314
|
+
*/
|
|
315
|
+
function decisionLine(d: DecisionRecord, now: number, closing?: string): string {
|
|
272
316
|
const flag = d.conditionMetAt === undefined ? "" : " [CONDITION MET — act on this now]";
|
|
273
|
-
|
|
317
|
+
const end = closing === undefined ? "" : ` — end with: omp-conductor ${closing}`;
|
|
318
|
+
return `- ${d.id} (${age(d.askedAt, now)}, blocks ${d.blocks ?? "nothing"})${flag}: ${d.question}${end}`;
|
|
274
319
|
}
|
package/src/depends-on.ts
CHANGED
|
@@ -18,9 +18,70 @@
|
|
|
18
18
|
* usable prerequisite, so it is reported as `malformed` for the grooming flag
|
|
19
19
|
* rather than guessed at, and the declaration it belongs to is ignored.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
21
|
+
* The module also owns the dependency-graph cycle pass (#421): {@link
|
|
22
|
+
* buildDependencyCycles} closes the graph over a pass's candidates and their
|
|
23
|
+
* reachable `Depends-on:` targets, and names every cycle so admission can hold
|
|
24
|
+
* the whole group rather than have an open first edge mask the cycle.
|
|
22
25
|
*/
|
|
23
26
|
|
|
27
|
+
import type { IssueState } from "./types.ts";
|
|
28
|
+
|
|
29
|
+
/** Canonical identity of one dependency-graph node: `owner/repo#n`. */
|
|
30
|
+
export interface DependencyNode {
|
|
31
|
+
readonly repo: string;
|
|
32
|
+
readonly issue: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Stable string key for {@link DependencyNode} — `owner/repo#n`. */
|
|
36
|
+
export function dependencyNodeKey(repo: string, issue: number): string {
|
|
37
|
+
return `${repo}#${issue}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One issue's live facts, enough to follow a dependency edge from it. */
|
|
41
|
+
export interface DependencyRead {
|
|
42
|
+
readonly state: IssueState;
|
|
43
|
+
readonly body: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reads one issue's state + body in either the tracker repo or a routed repo,
|
|
48
|
+
* or undefined when the repo is not a resolvable dependency target or the read
|
|
49
|
+
* could not be completed. Both cases fail closed — no edge is added — rather
|
|
50
|
+
* than ever synthesising a false cycle (#421).
|
|
51
|
+
*/
|
|
52
|
+
export type DependencyNodeReader = (
|
|
53
|
+
repo: string,
|
|
54
|
+
issue: number,
|
|
55
|
+
) => Promise<DependencyRead | undefined>;
|
|
56
|
+
|
|
57
|
+
/** Where a cross-repo `owner/repo#n` target resolves: the tracker repo, a
|
|
58
|
+
* routed repo, or nowhere (bounds traversal to the fleet's own repos). */
|
|
59
|
+
export type DependencyRepoResolver = (ownerRepo: string) => "tracker" | "routed" | undefined;
|
|
60
|
+
|
|
61
|
+
/** A detected dependency cycle, canonicalised so rotation or queue order cannot
|
|
62
|
+
* change its identity (and therefore cannot emit a new material event). */
|
|
63
|
+
export interface DependencyCycle {
|
|
64
|
+
/** Member node keys (`owner/repo#n`), for membership checks. */
|
|
65
|
+
readonly members: readonly string[];
|
|
66
|
+
/** The cycle as an ordered path from the canonical start, closing on itself.
|
|
67
|
+
* The display is stable under rotation and queue order. */
|
|
68
|
+
readonly path: readonly DependencyNode[];
|
|
69
|
+
/** {@link path} rendered: same-repo nodes as `#n`, others as `owner/repo#n`. */
|
|
70
|
+
readonly display: string;
|
|
71
|
+
/** The tracker-repo issue this cycle is reported against: the lowest global
|
|
72
|
+
* tracker issue among the members. A cycle reachable only through routed
|
|
73
|
+
* nodes falls back to its lowest member issue. */
|
|
74
|
+
readonly anchorIssue: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The output of one cycle pass over a set of candidates. */
|
|
78
|
+
export interface DependencyGraphResult {
|
|
79
|
+
/** Node keys of every node on at least one detected cycle. */
|
|
80
|
+
readonly cycleNodes: ReadonlySet<string>;
|
|
81
|
+
/** The distinct cycles found, canonicalised and deduplicated. */
|
|
82
|
+
readonly cycles: readonly DependencyCycle[];
|
|
83
|
+
}
|
|
84
|
+
|
|
24
85
|
export interface DependsOnDecl {
|
|
25
86
|
/** Referenced same-repo prerequisite issue numbers, deduplicated,
|
|
26
87
|
* first-seen order. */
|
|
@@ -120,3 +181,202 @@ export function parseDependsOn(body: string): DependsOnDecl {
|
|
|
120
181
|
}
|
|
121
182
|
return { refs, crossRefs, malformed };
|
|
122
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Enumerate every dependency cycle reachable from an admission pass's
|
|
187
|
+
* candidates. Built once per pass, before any per-candidate open-prerequisite
|
|
188
|
+
* hold, so a candidate sitting on a cycle is held as `dependency-cycle` rather
|
|
189
|
+
* than having its status masked by the first open edge (#421).
|
|
190
|
+
*
|
|
191
|
+
* The graph nodes are the candidates (bodies already parsed from `routed`,
|
|
192
|
+
* never a network read) plus every issue they reach through `Depends-on:`
|
|
193
|
+
* references — a bare `#n` resolves in the declaring node's own repo
|
|
194
|
+
* (tracker or routed), a cross-repo `owner/repo#n` in the tracker or a
|
|
195
|
+
* routed repo. Traversal is bounded in three ways:
|
|
196
|
+
* - references to a repo that is neither the tracker nor a routed repo are
|
|
197
|
+
* dropped before any read (they are the existing unroutable hold's job);
|
|
198
|
+
* - every node is expanded once, so each body/state read happens at most
|
|
199
|
+
* once per pass regardless of how many candidates reach it;
|
|
200
|
+
* - a closed node is terminal: it never propagates a cycle, because a
|
|
201
|
+
* dependency that is already done cannot hold anyone open.
|
|
202
|
+
* A node whose state or body cannot be read fails closed — it adds no edge and
|
|
203
|
+
* is never mistaken for a cycle; the per-candidate path then holds its
|
|
204
|
+
* originator on the existing dependency-unreadable route.
|
|
205
|
+
*
|
|
206
|
+
* Cycle membership is the graph's strongly connected components (Tarjan): a
|
|
207
|
+
* node sits on some directed cycle exactly when its component has more than
|
|
208
|
+
* one node or a self-edge, so overlapping cycles cannot hide a member behind
|
|
209
|
+
* an already-explored subtree. Each cyclic component is one cycle identity,
|
|
210
|
+
* with a deterministic simple path for status/digest.
|
|
211
|
+
*/
|
|
212
|
+
export async function buildDependencyCycles(
|
|
213
|
+
candidates: ReadonlyArray<{ repo: string; issue: number; body: string }>,
|
|
214
|
+
trackerRepo: string,
|
|
215
|
+
resolveRepo: DependencyRepoResolver,
|
|
216
|
+
read: DependencyNodeReader,
|
|
217
|
+
): Promise<DependencyGraphResult> {
|
|
218
|
+
const candidateBody = new Map<string, string>();
|
|
219
|
+
for (const c of candidates) {
|
|
220
|
+
candidateBody.set(dependencyNodeKey(c.repo, c.issue), c.body);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Materialise the reachable graph once: node key -> outgoing targets. A key
|
|
224
|
+
// present in `adj` is expanded — a candidate carries its body, a
|
|
225
|
+
// non-candidate is read at most once, and an unreadable or closed node is
|
|
226
|
+
// terminal (present, with no edges), so it is never read or re-expanded.
|
|
227
|
+
const adj = new Map<string, DependencyNode[]>();
|
|
228
|
+
const nodeOf = new Map<string, DependencyNode>();
|
|
229
|
+
|
|
230
|
+
const expand = async (node: DependencyNode): Promise<void> => {
|
|
231
|
+
const key = dependencyNodeKey(node.repo, node.issue);
|
|
232
|
+
if (adj.has(key)) return;
|
|
233
|
+
let body = candidateBody.get(key);
|
|
234
|
+
if (body === undefined) {
|
|
235
|
+
// Non-candidate node: read it fresh (once), fail closed on any doubt.
|
|
236
|
+
const r = await read(node.repo, node.issue);
|
|
237
|
+
if (r === undefined || r.state === "closed") {
|
|
238
|
+
// Unreadable never synthesises a cycle; a closed dependency is terminal.
|
|
239
|
+
adj.set(key, []);
|
|
240
|
+
nodeOf.set(key, { repo: node.repo, issue: node.issue });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
body = r.body;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const decl = parseDependsOn(body);
|
|
247
|
+
const targets: DependencyNode[] = [];
|
|
248
|
+
// A bare `#n` resolves in the declaring node's OWN repo: a body read from
|
|
249
|
+
// `acme/web#7` declares `acme/web#n`, never the tracker repo's #n.
|
|
250
|
+
for (const ref of decl.refs) {
|
|
251
|
+
targets.push({ repo: node.repo, issue: ref });
|
|
252
|
+
}
|
|
253
|
+
for (const cref of decl.crossRefs) {
|
|
254
|
+
const where = resolveRepo(cref.repo);
|
|
255
|
+
if (where === "tracker") targets.push({ repo: trackerRepo, issue: cref.issue });
|
|
256
|
+
else if (where === "routed") targets.push({ repo: cref.repo, issue: cref.issue });
|
|
257
|
+
// unresolved repos are dropped before any read: bounds traversal to the
|
|
258
|
+
// fleet's own tracker/routed repos (#421).
|
|
259
|
+
}
|
|
260
|
+
adj.set(key, targets);
|
|
261
|
+
nodeOf.set(key, { repo: node.repo, issue: node.issue });
|
|
262
|
+
for (const t of targets) {
|
|
263
|
+
await expand(t);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
for (const c of candidates) {
|
|
268
|
+
await expand({ repo: c.repo, issue: c.issue });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Tarjan's strongly connected components over the materialised graph. Every
|
|
272
|
+
// node on some directed cycle is in a cyclic component, so membership is
|
|
273
|
+
// complete where a single DFS path would miss a member behind an
|
|
274
|
+
// already-explored subtree (1 -> {2,3}, 3 -> 2, 2 -> 1: 3 is on the cycle
|
|
275
|
+
// 1 -> 3 -> 2 -> 1 even though the direct 1 <-> 2 pair is found first).
|
|
276
|
+
let nextIndex = 0;
|
|
277
|
+
const index = new Map<string, number>();
|
|
278
|
+
const low = new Map<string, number>();
|
|
279
|
+
const onStack = new Set<string>();
|
|
280
|
+
const stack: string[] = [];
|
|
281
|
+
const components: string[][] = [];
|
|
282
|
+
|
|
283
|
+
const strongConnect = (key: string): void => {
|
|
284
|
+
index.set(key, nextIndex);
|
|
285
|
+
low.set(key, nextIndex);
|
|
286
|
+
nextIndex += 1;
|
|
287
|
+
stack.push(key);
|
|
288
|
+
onStack.add(key);
|
|
289
|
+
for (const t of adj.get(key) ?? []) {
|
|
290
|
+
const targetKey = dependencyNodeKey(t.repo, t.issue);
|
|
291
|
+
if (!index.has(targetKey)) {
|
|
292
|
+
strongConnect(targetKey);
|
|
293
|
+
low.set(key, Math.min(low.get(key)!, low.get(targetKey)!));
|
|
294
|
+
} else if (onStack.has(targetKey)) {
|
|
295
|
+
low.set(key, Math.min(low.get(key)!, index.get(targetKey)!));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (low.get(key) === index.get(key)) {
|
|
299
|
+
const component: string[] = [];
|
|
300
|
+
let w: string;
|
|
301
|
+
do {
|
|
302
|
+
w = stack.pop()!;
|
|
303
|
+
onStack.delete(w);
|
|
304
|
+
component.push(w);
|
|
305
|
+
} while (w !== key);
|
|
306
|
+
components.push(component);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
for (const key of adj.keys()) {
|
|
310
|
+
if (!index.has(key)) strongConnect(key);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A deterministic simple directed cycle inside one cyclic component: DFS
|
|
314
|
+
* from the smallest member with children in key order; the first back edge
|
|
315
|
+
* closes a path. A true cyclic component always yields one — every member
|
|
316
|
+
* reaches every other, so the search cannot finish without crossing an
|
|
317
|
+
* ancestor. */
|
|
318
|
+
const findCyclePath = (
|
|
319
|
+
start: DependencyNode,
|
|
320
|
+
inComponent: ReadonlySet<string>,
|
|
321
|
+
): readonly DependencyNode[] | undefined => {
|
|
322
|
+
const path: DependencyNode[] = [start];
|
|
323
|
+
const position = new Map<string, number>([
|
|
324
|
+
[dependencyNodeKey(start.repo, start.issue), 0],
|
|
325
|
+
]);
|
|
326
|
+
const descend = (cur: DependencyNode): readonly DependencyNode[] | undefined => {
|
|
327
|
+
const children = (adj.get(dependencyNodeKey(cur.repo, cur.issue)) ?? [])
|
|
328
|
+
.filter((t) => inComponent.has(dependencyNodeKey(t.repo, t.issue)))
|
|
329
|
+
.sort((a, b) => {
|
|
330
|
+
const ka = dependencyNodeKey(a.repo, a.issue);
|
|
331
|
+
const kb = dependencyNodeKey(b.repo, b.issue);
|
|
332
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
333
|
+
});
|
|
334
|
+
for (const child of children) {
|
|
335
|
+
const childKey = dependencyNodeKey(child.repo, child.issue);
|
|
336
|
+
const at = position.get(childKey);
|
|
337
|
+
if (at !== undefined) {
|
|
338
|
+
// Back edge: the path from the ancestor to here, closed on `child`.
|
|
339
|
+
return [...path.slice(at), child];
|
|
340
|
+
}
|
|
341
|
+
position.set(childKey, path.length);
|
|
342
|
+
path.push(child);
|
|
343
|
+
const found = descend(child);
|
|
344
|
+
if (found !== undefined) return found;
|
|
345
|
+
path.pop();
|
|
346
|
+
position.delete(childKey);
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
};
|
|
350
|
+
return descend(start);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const cycles: DependencyCycle[] = [];
|
|
354
|
+
for (const component of components) {
|
|
355
|
+
const members = [...component].sort();
|
|
356
|
+
const hasSelfEdge = members.some((k) =>
|
|
357
|
+
(adj.get(k) ?? []).some((t) => dependencyNodeKey(t.repo, t.issue) === k),
|
|
358
|
+
);
|
|
359
|
+
if (members.length === 1 && !hasSelfEdge) continue; // terminal or acyclic
|
|
360
|
+
// Canonical start: the smallest member by owner/repo#n spelling, so
|
|
361
|
+
// rotation and queue order never produce a different identity.
|
|
362
|
+
const path = findCyclePath(nodeOf.get(members[0]!)!, new Set(component));
|
|
363
|
+
if (path === undefined) continue; // unreachable for a cyclic component; never fabricate
|
|
364
|
+
const display = path
|
|
365
|
+
.map((n) => (n.repo === trackerRepo ? `#${n.issue}` : `${n.repo}#${n.issue}`))
|
|
366
|
+
.join(" -> ");
|
|
367
|
+
const trackerMembers = members
|
|
368
|
+
.map((k) => k.split("#") as [string, string])
|
|
369
|
+
.filter(([repo]) => repo === trackerRepo)
|
|
370
|
+
.map(([, issue]) => Number(issue));
|
|
371
|
+
const anchorIssue =
|
|
372
|
+
trackerMembers.length > 0
|
|
373
|
+
? Math.min(...trackerMembers)
|
|
374
|
+
: Math.min(...members.map((k) => Number(k.split("#")[1])));
|
|
375
|
+
|
|
376
|
+
cycles.push({ members, path, display, anchorIssue });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const cycleNodes = new Set<string>();
|
|
380
|
+
for (const cyc of cycles) for (const m of cyc.members) cycleNodes.add(m);
|
|
381
|
+
return { cycleNodes, cycles };
|
|
382
|
+
}
|