@codyswann/lisa 2.94.0 → 2.96.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/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/scripts/queue-health-classification.mjs +157 -0
- package/plugins/lisa/scripts/queue-status-build-readers.mjs +454 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/src/base/scripts/queue-health-classification.mjs +157 -0
- package/plugins/src/base/scripts/queue-status-build-readers.mjs +454 -0
package/package.json
CHANGED
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"lodash": ">=4.18.1"
|
|
83
83
|
},
|
|
84
84
|
"name": "@codyswann/lisa",
|
|
85
|
-
"version": "2.
|
|
85
|
+
"version": "2.96.0",
|
|
86
86
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
87
87
|
"main": "dist/index.js",
|
|
88
88
|
"exports": {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared queue-health classification helpers for `/lisa:queue-status`.
|
|
4
|
+
*
|
|
5
|
+
* Queue readers normalize vendor-specific lifecycle data into the small set of
|
|
6
|
+
* signals below so queue-status can distinguish quiet, healthy, stuck, and
|
|
7
|
+
* misconfigured queues without inventing a second lifecycle vocabulary.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const QUEUE_HEALTH_VERDICTS = [
|
|
11
|
+
"IDLE",
|
|
12
|
+
"HEALTHY",
|
|
13
|
+
"ATTENTION_NEEDED",
|
|
14
|
+
"MISCONFIGURED",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {"IDLE" | "HEALTHY" | "ATTENTION_NEEDED" | "MISCONFIGURED"} QueueHealthVerdict
|
|
19
|
+
*
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* readonly queueResolved?: boolean
|
|
22
|
+
* readonly namespaceAdopted?: boolean
|
|
23
|
+
* readonly readyCount?: number
|
|
24
|
+
* readonly activeCount?: number
|
|
25
|
+
* readonly blockedCount?: number
|
|
26
|
+
* readonly stalledCount?: number
|
|
27
|
+
* readonly resolutionError?: string | null
|
|
28
|
+
* }} QueueHealthInput
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Classify a queue using the same high-level concepts intake and repair-intake
|
|
33
|
+
* already rely on:
|
|
34
|
+
* - queue must resolve from config;
|
|
35
|
+
* - lifecycle namespace must be adopted/present;
|
|
36
|
+
* - blocked or stalled work means operator attention is needed;
|
|
37
|
+
* - otherwise ready or active work is healthy;
|
|
38
|
+
* - otherwise the queue is truly idle.
|
|
39
|
+
*
|
|
40
|
+
* @param {QueueHealthInput} input
|
|
41
|
+
* @returns {{
|
|
42
|
+
* readonly verdict: QueueHealthVerdict
|
|
43
|
+
* readonly reasons: readonly string[]
|
|
44
|
+
* readonly counts: Readonly<{
|
|
45
|
+
* ready: number
|
|
46
|
+
* active: number
|
|
47
|
+
* blocked: number
|
|
48
|
+
* stalled: number
|
|
49
|
+
* attentionNeeded: number
|
|
50
|
+
* }>
|
|
51
|
+
* }}
|
|
52
|
+
*/
|
|
53
|
+
export function classifyQueueHealth(input = {}) {
|
|
54
|
+
const counts = {
|
|
55
|
+
ready: normalizeCount(input.readyCount),
|
|
56
|
+
active: normalizeCount(input.activeCount),
|
|
57
|
+
blocked: normalizeCount(input.blockedCount),
|
|
58
|
+
stalled: normalizeCount(input.stalledCount),
|
|
59
|
+
};
|
|
60
|
+
const attentionNeeded = counts.blocked + counts.stalled;
|
|
61
|
+
|
|
62
|
+
if (input.queueResolved === false || hasContent(input.resolutionError)) {
|
|
63
|
+
return {
|
|
64
|
+
verdict: "MISCONFIGURED",
|
|
65
|
+
reasons: ["queue-unresolved"],
|
|
66
|
+
counts: {
|
|
67
|
+
...counts,
|
|
68
|
+
attentionNeeded,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (input.namespaceAdopted === false) {
|
|
74
|
+
return {
|
|
75
|
+
verdict: "MISCONFIGURED",
|
|
76
|
+
reasons: ["lifecycle-namespace-absent"],
|
|
77
|
+
counts: {
|
|
78
|
+
...counts,
|
|
79
|
+
attentionNeeded,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (attentionNeeded > 0) {
|
|
85
|
+
return {
|
|
86
|
+
verdict: "ATTENTION_NEEDED",
|
|
87
|
+
reasons: [
|
|
88
|
+
counts.blocked > 0 ? "blocked-work-present" : null,
|
|
89
|
+
counts.stalled > 0 ? "stalled-work-present" : null,
|
|
90
|
+
].filter(Boolean),
|
|
91
|
+
counts: {
|
|
92
|
+
...counts,
|
|
93
|
+
attentionNeeded,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (counts.ready > 0 || counts.active > 0) {
|
|
99
|
+
return {
|
|
100
|
+
verdict: "HEALTHY",
|
|
101
|
+
reasons: [
|
|
102
|
+
counts.ready > 0 ? "ready-work-present" : null,
|
|
103
|
+
counts.active > 0 ? "active-work-in-flight" : null,
|
|
104
|
+
].filter(Boolean),
|
|
105
|
+
counts: {
|
|
106
|
+
...counts,
|
|
107
|
+
attentionNeeded,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
verdict: "IDLE",
|
|
114
|
+
reasons: ["no-actionable-work"],
|
|
115
|
+
counts: {
|
|
116
|
+
...counts,
|
|
117
|
+
attentionNeeded,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Combine individual queue verdicts into the overall queue-status verdict.
|
|
124
|
+
*
|
|
125
|
+
* @param {readonly { verdict: QueueHealthVerdict }[]} sections
|
|
126
|
+
* @returns {QueueHealthVerdict}
|
|
127
|
+
*/
|
|
128
|
+
export function computeOverallQueueVerdict(sections) {
|
|
129
|
+
const verdicts = sections.map(section => section.verdict);
|
|
130
|
+
|
|
131
|
+
if (verdicts.includes("MISCONFIGURED")) {
|
|
132
|
+
return "MISCONFIGURED";
|
|
133
|
+
}
|
|
134
|
+
if (verdicts.includes("ATTENTION_NEEDED")) {
|
|
135
|
+
return "ATTENTION_NEEDED";
|
|
136
|
+
}
|
|
137
|
+
if (verdicts.includes("HEALTHY")) {
|
|
138
|
+
return "HEALTHY";
|
|
139
|
+
}
|
|
140
|
+
return "IDLE";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {number | null | undefined} value
|
|
145
|
+
* @returns {number}
|
|
146
|
+
*/
|
|
147
|
+
function normalizeCount(value) {
|
|
148
|
+
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string | null | undefined} value
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
function hasContent(value) {
|
|
156
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
157
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared build-side queue readers for `/lisa:queue-status`.
|
|
4
|
+
*
|
|
5
|
+
* These helpers normalize vendor-specific build lifecycle items into a common
|
|
6
|
+
* snapshot shape so queue-status can report lifecycle counts, actionable
|
|
7
|
+
* highlights, and repair-intake signals without drifting from Lisa's build
|
|
8
|
+
* lifecycle contract.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { classifyQueueHealth } from "./queue-health-classification.mjs";
|
|
12
|
+
|
|
13
|
+
export const BUILD_LIFECYCLE_ORDER = [
|
|
14
|
+
"ready",
|
|
15
|
+
"claimed",
|
|
16
|
+
"review",
|
|
17
|
+
"blocked",
|
|
18
|
+
"done",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const ACTIONABLE_ROLE_ORDER = ["blocked", "ready", "claimed", "review"];
|
|
22
|
+
|
|
23
|
+
const HIGHLIGHT_COPY = {
|
|
24
|
+
blocked: {
|
|
25
|
+
summary: "Oldest blocked build item",
|
|
26
|
+
nextStep: "Run /lisa:repair-intake <queue> after clearing the blocker.",
|
|
27
|
+
},
|
|
28
|
+
stalled: {
|
|
29
|
+
summary: "Oldest stalled build item likely actionable for repair-intake",
|
|
30
|
+
nextStep:
|
|
31
|
+
"Run /lisa:repair-intake <queue> to re-evaluate the stalled build item.",
|
|
32
|
+
},
|
|
33
|
+
ready: {
|
|
34
|
+
summary: "Oldest ready build item awaiting intake",
|
|
35
|
+
nextStep: "Run /lisa:intake <queue> to claim the next build issue.",
|
|
36
|
+
},
|
|
37
|
+
claimed: {
|
|
38
|
+
summary: "Oldest claimed build item still in flight",
|
|
39
|
+
nextStep:
|
|
40
|
+
"Inspect the active implementation path before escalating to /lisa:repair-intake <queue>.",
|
|
41
|
+
},
|
|
42
|
+
review: {
|
|
43
|
+
summary: "Oldest build item waiting in review",
|
|
44
|
+
nextStep:
|
|
45
|
+
"Check the linked PR or review handoff before re-running /lisa:intake <queue>.",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read a GitHub-backed build queue snapshot from issue payloads.
|
|
51
|
+
*
|
|
52
|
+
* @param {{
|
|
53
|
+
* readonly issues?: readonly Record<string, any>[]
|
|
54
|
+
* readonly roles?: Record<string, any>
|
|
55
|
+
* readonly namespaceAdopted?: boolean
|
|
56
|
+
* readonly queueResolved?: boolean
|
|
57
|
+
* readonly queueArgument?: string
|
|
58
|
+
* readonly resolutionError?: string | null
|
|
59
|
+
* }} input
|
|
60
|
+
*/
|
|
61
|
+
export function readGithubBuildQueueSnapshot(input = {}) {
|
|
62
|
+
const roles = input.roles ?? {};
|
|
63
|
+
const normalizedItems = (input.issues ?? [])
|
|
64
|
+
.map(issue => normalizeGithubBuildIssue(issue, roles))
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
|
|
67
|
+
return createBuildQueueSnapshot({
|
|
68
|
+
tracker: "github",
|
|
69
|
+
items: normalizedItems,
|
|
70
|
+
roles,
|
|
71
|
+
namespaceAdopted: input.namespaceAdopted,
|
|
72
|
+
queueResolved: input.queueResolved,
|
|
73
|
+
queueArgument: input.queueArgument,
|
|
74
|
+
resolutionError: input.resolutionError,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a vendor-agnostic build queue snapshot from normalized lifecycle items.
|
|
80
|
+
*
|
|
81
|
+
* @param {{
|
|
82
|
+
* readonly tracker?: string
|
|
83
|
+
* readonly items?: readonly Record<string, any>[]
|
|
84
|
+
* readonly roles?: Record<string, any>
|
|
85
|
+
* readonly namespaceAdopted?: boolean
|
|
86
|
+
* readonly queueResolved?: boolean
|
|
87
|
+
* readonly queueArgument?: string
|
|
88
|
+
* readonly resolutionError?: string | null
|
|
89
|
+
* }} input
|
|
90
|
+
*/
|
|
91
|
+
export function createBuildQueueSnapshot(input = {}) {
|
|
92
|
+
const roles = normalizeRoles(input.roles);
|
|
93
|
+
const items = normalizeItems(input.items);
|
|
94
|
+
const counts = buildLifecycleCounts(items);
|
|
95
|
+
const repairSignals = buildRepairSignals(items, input.queueArgument);
|
|
96
|
+
const highlights = buildActionableHighlights(
|
|
97
|
+
items,
|
|
98
|
+
repairSignals,
|
|
99
|
+
input.queueArgument
|
|
100
|
+
);
|
|
101
|
+
const queueResolved =
|
|
102
|
+
input.queueResolved ?? typeof input.resolutionError !== "string";
|
|
103
|
+
const namespaceAdopted =
|
|
104
|
+
input.namespaceAdopted ?? inferNamespaceAdopted(items, roles);
|
|
105
|
+
|
|
106
|
+
const health = classifyQueueHealth({
|
|
107
|
+
queueResolved,
|
|
108
|
+
namespaceAdopted,
|
|
109
|
+
readyCount: counts.ready,
|
|
110
|
+
activeCount: counts.claimed + counts.review,
|
|
111
|
+
blockedCount: counts.blocked,
|
|
112
|
+
stalledCount: repairSignals.stalled.length,
|
|
113
|
+
resolutionError: input.resolutionError ?? null,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
tracker: input.tracker ?? "unknown",
|
|
118
|
+
queueResolved,
|
|
119
|
+
namespaceAdopted,
|
|
120
|
+
roles,
|
|
121
|
+
counts,
|
|
122
|
+
highlights,
|
|
123
|
+
repairSignals,
|
|
124
|
+
health,
|
|
125
|
+
resolutionError: input.resolutionError ?? null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {Record<string, any>} issue
|
|
131
|
+
* @param {Record<string, any>} roles
|
|
132
|
+
* @returns {Record<string, any> | null}
|
|
133
|
+
*/
|
|
134
|
+
function normalizeGithubBuildIssue(issue, roles) {
|
|
135
|
+
if (!issue || typeof issue !== "object") {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const role = resolveGithubBuildRole(issue.labels, roles);
|
|
140
|
+
|
|
141
|
+
return normalizeItem({
|
|
142
|
+
id: String(issue.id ?? issue.number ?? issue.url ?? issue.title ?? ""),
|
|
143
|
+
ref:
|
|
144
|
+
issue.number !== undefined && issue.number !== null
|
|
145
|
+
? `#${issue.number}`
|
|
146
|
+
: String(issue.url ?? issue.title ?? ""),
|
|
147
|
+
title: String(issue.title ?? "").trim(),
|
|
148
|
+
url: typeof issue.url === "string" ? issue.url : null,
|
|
149
|
+
createdAt: issue.createdAt ?? null,
|
|
150
|
+
updatedAt: issue.updatedAt ?? null,
|
|
151
|
+
role,
|
|
152
|
+
stalled: Boolean(issue.stalled),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {readonly any[] | undefined} labels
|
|
158
|
+
* @param {Record<string, any>} roles
|
|
159
|
+
* @returns {string | null}
|
|
160
|
+
*/
|
|
161
|
+
function resolveGithubBuildRole(labels, roles) {
|
|
162
|
+
if (!Array.isArray(labels)) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const labelNames = new Set(
|
|
167
|
+
labels
|
|
168
|
+
.map(label =>
|
|
169
|
+
typeof label === "string"
|
|
170
|
+
? label
|
|
171
|
+
: typeof label?.name === "string"
|
|
172
|
+
? label.name
|
|
173
|
+
: null
|
|
174
|
+
)
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
for (const role of BUILD_LIFECYCLE_ORDER) {
|
|
179
|
+
if (role === "done") {
|
|
180
|
+
if (resolveDoneRoleNames(roles).some(name => labelNames.has(name))) {
|
|
181
|
+
return "done";
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const configuredName = roles[role];
|
|
187
|
+
if (configuredName && labelNames.has(configuredName)) {
|
|
188
|
+
return role;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {Record<string, any> | undefined} roles
|
|
197
|
+
* @returns {Record<string, any>}
|
|
198
|
+
*/
|
|
199
|
+
function normalizeRoles(roles) {
|
|
200
|
+
return {
|
|
201
|
+
ready:
|
|
202
|
+
typeof roles?.ready === "string" && roles.ready.trim().length > 0
|
|
203
|
+
? roles.ready.trim()
|
|
204
|
+
: "ready",
|
|
205
|
+
claimed:
|
|
206
|
+
typeof roles?.claimed === "string" && roles.claimed.trim().length > 0
|
|
207
|
+
? roles.claimed.trim()
|
|
208
|
+
: "claimed",
|
|
209
|
+
review:
|
|
210
|
+
typeof roles?.review === "string" && roles.review.trim().length > 0
|
|
211
|
+
? roles.review.trim()
|
|
212
|
+
: "review",
|
|
213
|
+
blocked:
|
|
214
|
+
typeof roles?.blocked === "string" && roles.blocked.trim().length > 0
|
|
215
|
+
? roles.blocked.trim()
|
|
216
|
+
: "blocked",
|
|
217
|
+
done: normalizeDoneRoles(roles?.done),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {Record<string, any> | undefined} done
|
|
223
|
+
* @returns {Record<string, string>}
|
|
224
|
+
*/
|
|
225
|
+
function normalizeDoneRoles(done) {
|
|
226
|
+
if (typeof done === "string" && done.trim().length > 0) {
|
|
227
|
+
return { default: done.trim() };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const normalized = {};
|
|
231
|
+
for (const [key, value] of Object.entries(done ?? {})) {
|
|
232
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
233
|
+
normalized[key] = value.trim();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return Object.keys(normalized).length > 0 ? normalized : { default: "done" };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {Record<string, any>} roles
|
|
242
|
+
* @returns {readonly string[]}
|
|
243
|
+
*/
|
|
244
|
+
function resolveDoneRoleNames(roles) {
|
|
245
|
+
return Object.values(normalizeDoneRoles(roles?.done));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {readonly Record<string, any>[] | undefined} items
|
|
250
|
+
* @returns {readonly Record<string, any>[]}
|
|
251
|
+
*/
|
|
252
|
+
function normalizeItems(items) {
|
|
253
|
+
return (items ?? []).map(normalizeItem).filter(Boolean);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {Record<string, any>} item
|
|
258
|
+
* @returns {Record<string, any> | null}
|
|
259
|
+
*/
|
|
260
|
+
function normalizeItem(item) {
|
|
261
|
+
if (!item || typeof item !== "object") {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const role =
|
|
266
|
+
typeof item.role === "string" && BUILD_LIFECYCLE_ORDER.includes(item.role)
|
|
267
|
+
? item.role
|
|
268
|
+
: null;
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
id: String(item.id ?? item.ref ?? item.title ?? ""),
|
|
272
|
+
ref: String(item.ref ?? item.id ?? item.title ?? ""),
|
|
273
|
+
title: String(item.title ?? "").trim(),
|
|
274
|
+
url: typeof item.url === "string" ? item.url : null,
|
|
275
|
+
role,
|
|
276
|
+
createdAt: normalizeTimestamp(item.createdAt),
|
|
277
|
+
updatedAt: normalizeTimestamp(item.updatedAt),
|
|
278
|
+
stalled: Boolean(item.stalled),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {readonly Record<string, any>[]} items
|
|
284
|
+
*/
|
|
285
|
+
function buildLifecycleCounts(items) {
|
|
286
|
+
const counts = Object.fromEntries(
|
|
287
|
+
BUILD_LIFECYCLE_ORDER.map(role => [role, 0])
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
for (const item of items) {
|
|
291
|
+
if (item.role && counts[item.role] !== undefined) {
|
|
292
|
+
counts[item.role] += 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return counts;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @param {readonly Record<string, any>[]} items
|
|
301
|
+
* @param {string | undefined} queueArgument
|
|
302
|
+
*/
|
|
303
|
+
function buildRepairSignals(items, queueArgument) {
|
|
304
|
+
const blocked = items
|
|
305
|
+
.filter(item => item.role === "blocked")
|
|
306
|
+
.sort(compareQueueItemsByCreatedAt)
|
|
307
|
+
.map(toRepairSignalItem);
|
|
308
|
+
const stalled = items
|
|
309
|
+
.filter(item => item.stalled)
|
|
310
|
+
.sort(compareQueueItemsByCreatedAt)
|
|
311
|
+
.map(toRepairSignalItem);
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
actionable: blocked.length > 0 || stalled.length > 0,
|
|
315
|
+
blocked,
|
|
316
|
+
stalled,
|
|
317
|
+
suggestedCommand:
|
|
318
|
+
blocked.length > 0 || stalled.length > 0
|
|
319
|
+
? expandHighlightNextStep(
|
|
320
|
+
"Run /lisa:repair-intake <queue> to inspect the most actionable stuck build work.",
|
|
321
|
+
queueArgument
|
|
322
|
+
)
|
|
323
|
+
: null,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {readonly Record<string, any>[]} items
|
|
329
|
+
* @param {Record<string, any>} repairSignals
|
|
330
|
+
* @param {string | undefined} queueArgument
|
|
331
|
+
*/
|
|
332
|
+
function buildActionableHighlights(items, repairSignals, queueArgument) {
|
|
333
|
+
const highlights = [];
|
|
334
|
+
|
|
335
|
+
const oldestStalled = repairSignals.stalled[0];
|
|
336
|
+
if (oldestStalled) {
|
|
337
|
+
highlights.push({
|
|
338
|
+
role: "stalled",
|
|
339
|
+
ref: oldestStalled.ref,
|
|
340
|
+
title: oldestStalled.title,
|
|
341
|
+
url: oldestStalled.url,
|
|
342
|
+
createdAt: oldestStalled.createdAt,
|
|
343
|
+
summary: HIGHLIGHT_COPY.stalled.summary,
|
|
344
|
+
nextStep: expandHighlightNextStep(
|
|
345
|
+
HIGHLIGHT_COPY.stalled.nextStep,
|
|
346
|
+
queueArgument
|
|
347
|
+
),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const role of ACTIONABLE_ROLE_ORDER) {
|
|
352
|
+
const oldest = findOldestItemForRole(items, role);
|
|
353
|
+
if (!oldest) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const copy = HIGHLIGHT_COPY[role];
|
|
358
|
+
highlights.push({
|
|
359
|
+
role,
|
|
360
|
+
ref: oldest.ref,
|
|
361
|
+
title: oldest.title,
|
|
362
|
+
url: oldest.url,
|
|
363
|
+
createdAt: oldest.createdAt,
|
|
364
|
+
summary: copy.summary,
|
|
365
|
+
nextStep: expandHighlightNextStep(copy.nextStep, queueArgument),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return highlights;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {readonly Record<string, any>[]} items
|
|
374
|
+
* @param {string} role
|
|
375
|
+
*/
|
|
376
|
+
function findOldestItemForRole(items, role) {
|
|
377
|
+
return (
|
|
378
|
+
items
|
|
379
|
+
.filter(item => item.role === role)
|
|
380
|
+
.sort(compareQueueItemsByCreatedAt)[0] ?? null
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* @param {readonly Record<string, any>[]} items
|
|
386
|
+
* @param {Record<string, any>} roles
|
|
387
|
+
* @returns {boolean}
|
|
388
|
+
*/
|
|
389
|
+
function inferNamespaceAdopted(items, roles) {
|
|
390
|
+
if (items.some(item => item.role)) {
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
["ready", "claimed", "review", "blocked"].some(
|
|
396
|
+
role => typeof roles[role] === "string" && roles[role].trim().length > 0
|
|
397
|
+
) || resolveDoneRoleNames(roles).length > 0
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @param {Record<string, any>} item
|
|
403
|
+
* @returns {Record<string, any>}
|
|
404
|
+
*/
|
|
405
|
+
function toRepairSignalItem(item) {
|
|
406
|
+
return {
|
|
407
|
+
ref: item.ref,
|
|
408
|
+
title: item.title,
|
|
409
|
+
url: item.url,
|
|
410
|
+
createdAt: item.createdAt,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {string} template
|
|
416
|
+
* @param {string | undefined} queueArgument
|
|
417
|
+
* @returns {string}
|
|
418
|
+
*/
|
|
419
|
+
function expandHighlightNextStep(template, queueArgument) {
|
|
420
|
+
return template.replace("<queue>", queueArgument ?? "queue");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* @param {Record<string, any>} left
|
|
425
|
+
* @param {Record<string, any>} right
|
|
426
|
+
* @returns {number}
|
|
427
|
+
*/
|
|
428
|
+
function compareQueueItemsByCreatedAt(left, right) {
|
|
429
|
+
const leftMs = left.createdAt
|
|
430
|
+
? Date.parse(left.createdAt)
|
|
431
|
+
: Number.POSITIVE_INFINITY;
|
|
432
|
+
const rightMs = right.createdAt
|
|
433
|
+
? Date.parse(right.createdAt)
|
|
434
|
+
: Number.POSITIVE_INFINITY;
|
|
435
|
+
|
|
436
|
+
if (leftMs !== rightMs) {
|
|
437
|
+
return leftMs - rightMs;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return String(left.ref).localeCompare(String(right.ref));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* @param {string | null | undefined} value
|
|
445
|
+
* @returns {string | null}
|
|
446
|
+
*/
|
|
447
|
+
function normalizeTimestamp(value) {
|
|
448
|
+
if (typeof value !== "string") {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const trimmed = value.trim();
|
|
453
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
454
|
+
}
|