@codyswann/lisa 2.96.0 → 2.97.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-status-prd-readers.mjs +359 -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-status-prd-readers.mjs +359 -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.97.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,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared PRD-side queue readers for `/lisa:queue-status`.
|
|
4
|
+
*
|
|
5
|
+
* These helpers normalize vendor-specific PRD lifecycle items into a common
|
|
6
|
+
* snapshot shape so queue-status can report lifecycle counts, actionable
|
|
7
|
+
* highlights, and queue-health verdict inputs without drifting from Lisa's PRD
|
|
8
|
+
* lifecycle contract.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { classifyQueueHealth } from "./queue-health-classification.mjs";
|
|
12
|
+
|
|
13
|
+
export const PRD_LIFECYCLE_ORDER = [
|
|
14
|
+
"draft",
|
|
15
|
+
"ready",
|
|
16
|
+
"in_review",
|
|
17
|
+
"blocked",
|
|
18
|
+
"ticketed",
|
|
19
|
+
"shipped",
|
|
20
|
+
"verified",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const ACTIONABLE_ROLE_ORDER = [
|
|
24
|
+
"blocked",
|
|
25
|
+
"in_review",
|
|
26
|
+
"shipped",
|
|
27
|
+
"ready",
|
|
28
|
+
"ticketed",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const HIGHLIGHT_COPY = {
|
|
32
|
+
blocked: {
|
|
33
|
+
summary: "Oldest blocked PRD",
|
|
34
|
+
nextStep: "Run /lisa:repair-intake <queue> after clarifying the blocker.",
|
|
35
|
+
},
|
|
36
|
+
in_review: {
|
|
37
|
+
summary: "Oldest PRD still in review",
|
|
38
|
+
nextStep:
|
|
39
|
+
"Inspect the active intake run or resume it with /lisa:repair-intake <queue>.",
|
|
40
|
+
},
|
|
41
|
+
shipped: {
|
|
42
|
+
summary: "Oldest shipped PRD awaiting verification",
|
|
43
|
+
nextStep: "Run /lisa:verify-prd <item-url> to close the shipped loop.",
|
|
44
|
+
},
|
|
45
|
+
ready: {
|
|
46
|
+
summary: "Oldest ready PRD awaiting intake",
|
|
47
|
+
nextStep: "Run /lisa:intake <queue> to ticket the next PRD.",
|
|
48
|
+
},
|
|
49
|
+
ticketed: {
|
|
50
|
+
summary: "Oldest ticketed PRD still waiting on downstream delivery",
|
|
51
|
+
nextStep:
|
|
52
|
+
"Monitor downstream build work or inspect the build queue with /lisa:queue-status queue=build.",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read a GitHub-backed PRD queue snapshot from issue payloads.
|
|
58
|
+
*
|
|
59
|
+
* @param {{
|
|
60
|
+
* readonly issues?: readonly Record<string, any>[]
|
|
61
|
+
* readonly roles?: Record<string, string>
|
|
62
|
+
* readonly namespaceAdopted?: boolean
|
|
63
|
+
* readonly queueResolved?: boolean
|
|
64
|
+
* readonly queueArgument?: string
|
|
65
|
+
* readonly resolutionError?: string | null
|
|
66
|
+
* }} input
|
|
67
|
+
*/
|
|
68
|
+
export function readGithubPrdQueueSnapshot(input = {}) {
|
|
69
|
+
const roles = input.roles ?? {};
|
|
70
|
+
const normalizedItems = (input.issues ?? [])
|
|
71
|
+
.map(issue => normalizeGithubPrdIssue(issue, roles))
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
|
|
74
|
+
return createPrdQueueSnapshot({
|
|
75
|
+
source: "github",
|
|
76
|
+
items: normalizedItems,
|
|
77
|
+
roles,
|
|
78
|
+
namespaceAdopted: input.namespaceAdopted,
|
|
79
|
+
queueResolved: input.queueResolved,
|
|
80
|
+
queueArgument: input.queueArgument,
|
|
81
|
+
resolutionError: input.resolutionError,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a vendor-agnostic PRD queue snapshot from normalized lifecycle items.
|
|
87
|
+
*
|
|
88
|
+
* @param {{
|
|
89
|
+
* readonly source?: string
|
|
90
|
+
* readonly items?: readonly Record<string, any>[]
|
|
91
|
+
* readonly roles?: Record<string, string>
|
|
92
|
+
* readonly namespaceAdopted?: boolean
|
|
93
|
+
* readonly queueResolved?: boolean
|
|
94
|
+
* readonly queueArgument?: string
|
|
95
|
+
* readonly resolutionError?: string | null
|
|
96
|
+
* }} input
|
|
97
|
+
*/
|
|
98
|
+
export function createPrdQueueSnapshot(input = {}) {
|
|
99
|
+
const rawRoles = input.roles ?? {};
|
|
100
|
+
const roles = normalizeRoles(rawRoles);
|
|
101
|
+
const items = normalizeItems(input.items);
|
|
102
|
+
const counts = buildLifecycleCounts(items);
|
|
103
|
+
const highlights = buildActionableHighlights(items, input.queueArgument);
|
|
104
|
+
const queueResolved =
|
|
105
|
+
input.queueResolved ?? typeof input.resolutionError !== "string";
|
|
106
|
+
const namespaceAdopted =
|
|
107
|
+
input.namespaceAdopted ?? inferNamespaceAdopted(items, rawRoles);
|
|
108
|
+
|
|
109
|
+
const health = classifyQueueHealth({
|
|
110
|
+
queueResolved,
|
|
111
|
+
namespaceAdopted,
|
|
112
|
+
readyCount: counts.ready,
|
|
113
|
+
activeCount: counts.in_review + counts.ticketed,
|
|
114
|
+
blockedCount: counts.blocked,
|
|
115
|
+
stalledCount: counts.shipped,
|
|
116
|
+
resolutionError: input.resolutionError ?? null,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
source: input.source ?? "unknown",
|
|
121
|
+
queueResolved,
|
|
122
|
+
namespaceAdopted,
|
|
123
|
+
roles,
|
|
124
|
+
counts,
|
|
125
|
+
highlights,
|
|
126
|
+
health,
|
|
127
|
+
resolutionError: input.resolutionError ?? null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {Record<string, any>} issue
|
|
133
|
+
* @param {Record<string, string>} roles
|
|
134
|
+
* @returns {Record<string, any> | null}
|
|
135
|
+
*/
|
|
136
|
+
function normalizeGithubPrdIssue(issue, roles) {
|
|
137
|
+
if (!issue || typeof issue !== "object") {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const role = resolveGithubPrdRole(issue.labels, roles);
|
|
142
|
+
|
|
143
|
+
return normalizeItem({
|
|
144
|
+
id: String(issue.id ?? issue.number ?? issue.url ?? issue.title ?? ""),
|
|
145
|
+
ref:
|
|
146
|
+
issue.number !== undefined && issue.number !== null
|
|
147
|
+
? `#${issue.number}`
|
|
148
|
+
: String(issue.url ?? issue.title ?? ""),
|
|
149
|
+
title: String(issue.title ?? "").trim(),
|
|
150
|
+
url: typeof issue.url === "string" ? issue.url : null,
|
|
151
|
+
createdAt: issue.createdAt ?? null,
|
|
152
|
+
updatedAt: issue.updatedAt ?? null,
|
|
153
|
+
role,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {readonly any[] | undefined} labels
|
|
159
|
+
* @param {Record<string, string>} roles
|
|
160
|
+
* @returns {string | null}
|
|
161
|
+
*/
|
|
162
|
+
function resolveGithubPrdRole(labels, roles) {
|
|
163
|
+
if (!Array.isArray(labels)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const labelNames = new Set(
|
|
168
|
+
labels
|
|
169
|
+
.map(label =>
|
|
170
|
+
typeof label === "string"
|
|
171
|
+
? label
|
|
172
|
+
: typeof label?.name === "string"
|
|
173
|
+
? label.name
|
|
174
|
+
: null
|
|
175
|
+
)
|
|
176
|
+
.filter(Boolean)
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
for (const role of PRD_LIFECYCLE_ORDER) {
|
|
180
|
+
const configuredName = roles[role];
|
|
181
|
+
if (configuredName && labelNames.has(configuredName)) {
|
|
182
|
+
return role;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {Record<string, string> | undefined} roles
|
|
191
|
+
* @returns {Record<string, string>}
|
|
192
|
+
*/
|
|
193
|
+
function normalizeRoles(roles) {
|
|
194
|
+
const normalized = {};
|
|
195
|
+
|
|
196
|
+
for (const role of PRD_LIFECYCLE_ORDER) {
|
|
197
|
+
normalized[role] =
|
|
198
|
+
typeof roles?.[role] === "string" && roles[role].trim().length > 0
|
|
199
|
+
? roles[role].trim()
|
|
200
|
+
: role;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return normalized;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {readonly Record<string, any>[] | undefined} items
|
|
208
|
+
* @returns {readonly Record<string, any>[]}
|
|
209
|
+
*/
|
|
210
|
+
function normalizeItems(items) {
|
|
211
|
+
return (items ?? []).map(normalizeItem).filter(Boolean);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {Record<string, any>} item
|
|
216
|
+
* @returns {Record<string, any> | null}
|
|
217
|
+
*/
|
|
218
|
+
function normalizeItem(item) {
|
|
219
|
+
if (!item || typeof item !== "object") {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const role =
|
|
224
|
+
typeof item.role === "string" && PRD_LIFECYCLE_ORDER.includes(item.role)
|
|
225
|
+
? item.role
|
|
226
|
+
: null;
|
|
227
|
+
|
|
228
|
+
const createdAt = normalizeTimestamp(item.createdAt);
|
|
229
|
+
const updatedAt = normalizeTimestamp(item.updatedAt);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
id: String(item.id ?? item.ref ?? item.title ?? ""),
|
|
233
|
+
ref: String(item.ref ?? item.id ?? item.title ?? ""),
|
|
234
|
+
title: String(item.title ?? "").trim(),
|
|
235
|
+
url: typeof item.url === "string" ? item.url : null,
|
|
236
|
+
role,
|
|
237
|
+
createdAt,
|
|
238
|
+
updatedAt,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {readonly Record<string, any>[]} items
|
|
244
|
+
*/
|
|
245
|
+
function buildLifecycleCounts(items) {
|
|
246
|
+
const counts = Object.fromEntries(PRD_LIFECYCLE_ORDER.map(role => [role, 0]));
|
|
247
|
+
|
|
248
|
+
for (const item of items) {
|
|
249
|
+
if (item.role && counts[item.role] !== undefined) {
|
|
250
|
+
counts[item.role] += 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return counts;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {readonly Record<string, any>[]} items
|
|
259
|
+
* @param {string | undefined} queueArgument
|
|
260
|
+
*/
|
|
261
|
+
function buildActionableHighlights(items, queueArgument) {
|
|
262
|
+
const highlights = [];
|
|
263
|
+
|
|
264
|
+
for (const role of ACTIONABLE_ROLE_ORDER) {
|
|
265
|
+
const oldest = findOldestItemForRole(items, role);
|
|
266
|
+
if (!oldest) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const copy = HIGHLIGHT_COPY[role];
|
|
271
|
+
highlights.push({
|
|
272
|
+
role,
|
|
273
|
+
ref: oldest.ref,
|
|
274
|
+
title: oldest.title,
|
|
275
|
+
url: oldest.url,
|
|
276
|
+
createdAt: oldest.createdAt,
|
|
277
|
+
summary: copy.summary,
|
|
278
|
+
nextStep: expandHighlightNextStep(
|
|
279
|
+
copy.nextStep,
|
|
280
|
+
queueArgument,
|
|
281
|
+
oldest.url
|
|
282
|
+
),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return highlights;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {readonly Record<string, any>[]} items
|
|
291
|
+
* @param {string} role
|
|
292
|
+
*/
|
|
293
|
+
function findOldestItemForRole(items, role) {
|
|
294
|
+
return (
|
|
295
|
+
items
|
|
296
|
+
.filter(item => item.role === role)
|
|
297
|
+
.sort(compareQueueItemsByCreatedAt)[0] ?? null
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {readonly Record<string, any>[]} items
|
|
303
|
+
* @param {Record<string, string>} roles
|
|
304
|
+
* @returns {boolean}
|
|
305
|
+
*/
|
|
306
|
+
function inferNamespaceAdopted(items, roles) {
|
|
307
|
+
if (items.some(item => item.role)) {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return Object.values(roles).some(
|
|
312
|
+
value => typeof value === "string" && value.trim().length > 0
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {string} template
|
|
318
|
+
* @param {string | undefined} queueArgument
|
|
319
|
+
* @param {string | null} itemUrl
|
|
320
|
+
* @returns {string}
|
|
321
|
+
*/
|
|
322
|
+
function expandHighlightNextStep(template, queueArgument, itemUrl) {
|
|
323
|
+
return template
|
|
324
|
+
.replace("<queue>", queueArgument ?? "queue")
|
|
325
|
+
.replace("<item-url>", itemUrl ?? "item URL");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* @param {Record<string, any>} left
|
|
330
|
+
* @param {Record<string, any>} right
|
|
331
|
+
* @returns {number}
|
|
332
|
+
*/
|
|
333
|
+
function compareQueueItemsByCreatedAt(left, right) {
|
|
334
|
+
const leftMs = left.createdAt
|
|
335
|
+
? Date.parse(left.createdAt)
|
|
336
|
+
: Number.POSITIVE_INFINITY;
|
|
337
|
+
const rightMs = right.createdAt
|
|
338
|
+
? Date.parse(right.createdAt)
|
|
339
|
+
: Number.POSITIVE_INFINITY;
|
|
340
|
+
|
|
341
|
+
if (leftMs !== rightMs) {
|
|
342
|
+
return leftMs - rightMs;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return String(left.ref).localeCompare(String(right.ref));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {string | null | undefined} value
|
|
350
|
+
* @returns {string | null}
|
|
351
|
+
*/
|
|
352
|
+
function normalizeTimestamp(value) {
|
|
353
|
+
if (typeof value !== "string") {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const trimmed = value.trim();
|
|
358
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
359
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.97.0",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.97.0",
|
|
4
4
|
"description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared PRD-side queue readers for `/lisa:queue-status`.
|
|
4
|
+
*
|
|
5
|
+
* These helpers normalize vendor-specific PRD lifecycle items into a common
|
|
6
|
+
* snapshot shape so queue-status can report lifecycle counts, actionable
|
|
7
|
+
* highlights, and queue-health verdict inputs without drifting from Lisa's PRD
|
|
8
|
+
* lifecycle contract.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { classifyQueueHealth } from "./queue-health-classification.mjs";
|
|
12
|
+
|
|
13
|
+
export const PRD_LIFECYCLE_ORDER = [
|
|
14
|
+
"draft",
|
|
15
|
+
"ready",
|
|
16
|
+
"in_review",
|
|
17
|
+
"blocked",
|
|
18
|
+
"ticketed",
|
|
19
|
+
"shipped",
|
|
20
|
+
"verified",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const ACTIONABLE_ROLE_ORDER = [
|
|
24
|
+
"blocked",
|
|
25
|
+
"in_review",
|
|
26
|
+
"shipped",
|
|
27
|
+
"ready",
|
|
28
|
+
"ticketed",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const HIGHLIGHT_COPY = {
|
|
32
|
+
blocked: {
|
|
33
|
+
summary: "Oldest blocked PRD",
|
|
34
|
+
nextStep: "Run /lisa:repair-intake <queue> after clarifying the blocker.",
|
|
35
|
+
},
|
|
36
|
+
in_review: {
|
|
37
|
+
summary: "Oldest PRD still in review",
|
|
38
|
+
nextStep:
|
|
39
|
+
"Inspect the active intake run or resume it with /lisa:repair-intake <queue>.",
|
|
40
|
+
},
|
|
41
|
+
shipped: {
|
|
42
|
+
summary: "Oldest shipped PRD awaiting verification",
|
|
43
|
+
nextStep: "Run /lisa:verify-prd <item-url> to close the shipped loop.",
|
|
44
|
+
},
|
|
45
|
+
ready: {
|
|
46
|
+
summary: "Oldest ready PRD awaiting intake",
|
|
47
|
+
nextStep: "Run /lisa:intake <queue> to ticket the next PRD.",
|
|
48
|
+
},
|
|
49
|
+
ticketed: {
|
|
50
|
+
summary: "Oldest ticketed PRD still waiting on downstream delivery",
|
|
51
|
+
nextStep:
|
|
52
|
+
"Monitor downstream build work or inspect the build queue with /lisa:queue-status queue=build.",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read a GitHub-backed PRD queue snapshot from issue payloads.
|
|
58
|
+
*
|
|
59
|
+
* @param {{
|
|
60
|
+
* readonly issues?: readonly Record<string, any>[]
|
|
61
|
+
* readonly roles?: Record<string, string>
|
|
62
|
+
* readonly namespaceAdopted?: boolean
|
|
63
|
+
* readonly queueResolved?: boolean
|
|
64
|
+
* readonly queueArgument?: string
|
|
65
|
+
* readonly resolutionError?: string | null
|
|
66
|
+
* }} input
|
|
67
|
+
*/
|
|
68
|
+
export function readGithubPrdQueueSnapshot(input = {}) {
|
|
69
|
+
const roles = input.roles ?? {};
|
|
70
|
+
const normalizedItems = (input.issues ?? [])
|
|
71
|
+
.map(issue => normalizeGithubPrdIssue(issue, roles))
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
|
|
74
|
+
return createPrdQueueSnapshot({
|
|
75
|
+
source: "github",
|
|
76
|
+
items: normalizedItems,
|
|
77
|
+
roles,
|
|
78
|
+
namespaceAdopted: input.namespaceAdopted,
|
|
79
|
+
queueResolved: input.queueResolved,
|
|
80
|
+
queueArgument: input.queueArgument,
|
|
81
|
+
resolutionError: input.resolutionError,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a vendor-agnostic PRD queue snapshot from normalized lifecycle items.
|
|
87
|
+
*
|
|
88
|
+
* @param {{
|
|
89
|
+
* readonly source?: string
|
|
90
|
+
* readonly items?: readonly Record<string, any>[]
|
|
91
|
+
* readonly roles?: Record<string, string>
|
|
92
|
+
* readonly namespaceAdopted?: boolean
|
|
93
|
+
* readonly queueResolved?: boolean
|
|
94
|
+
* readonly queueArgument?: string
|
|
95
|
+
* readonly resolutionError?: string | null
|
|
96
|
+
* }} input
|
|
97
|
+
*/
|
|
98
|
+
export function createPrdQueueSnapshot(input = {}) {
|
|
99
|
+
const rawRoles = input.roles ?? {};
|
|
100
|
+
const roles = normalizeRoles(rawRoles);
|
|
101
|
+
const items = normalizeItems(input.items);
|
|
102
|
+
const counts = buildLifecycleCounts(items);
|
|
103
|
+
const highlights = buildActionableHighlights(items, input.queueArgument);
|
|
104
|
+
const queueResolved =
|
|
105
|
+
input.queueResolved ?? typeof input.resolutionError !== "string";
|
|
106
|
+
const namespaceAdopted =
|
|
107
|
+
input.namespaceAdopted ?? inferNamespaceAdopted(items, rawRoles);
|
|
108
|
+
|
|
109
|
+
const health = classifyQueueHealth({
|
|
110
|
+
queueResolved,
|
|
111
|
+
namespaceAdopted,
|
|
112
|
+
readyCount: counts.ready,
|
|
113
|
+
activeCount: counts.in_review + counts.ticketed,
|
|
114
|
+
blockedCount: counts.blocked,
|
|
115
|
+
stalledCount: counts.shipped,
|
|
116
|
+
resolutionError: input.resolutionError ?? null,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
source: input.source ?? "unknown",
|
|
121
|
+
queueResolved,
|
|
122
|
+
namespaceAdopted,
|
|
123
|
+
roles,
|
|
124
|
+
counts,
|
|
125
|
+
highlights,
|
|
126
|
+
health,
|
|
127
|
+
resolutionError: input.resolutionError ?? null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {Record<string, any>} issue
|
|
133
|
+
* @param {Record<string, string>} roles
|
|
134
|
+
* @returns {Record<string, any> | null}
|
|
135
|
+
*/
|
|
136
|
+
function normalizeGithubPrdIssue(issue, roles) {
|
|
137
|
+
if (!issue || typeof issue !== "object") {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const role = resolveGithubPrdRole(issue.labels, roles);
|
|
142
|
+
|
|
143
|
+
return normalizeItem({
|
|
144
|
+
id: String(issue.id ?? issue.number ?? issue.url ?? issue.title ?? ""),
|
|
145
|
+
ref:
|
|
146
|
+
issue.number !== undefined && issue.number !== null
|
|
147
|
+
? `#${issue.number}`
|
|
148
|
+
: String(issue.url ?? issue.title ?? ""),
|
|
149
|
+
title: String(issue.title ?? "").trim(),
|
|
150
|
+
url: typeof issue.url === "string" ? issue.url : null,
|
|
151
|
+
createdAt: issue.createdAt ?? null,
|
|
152
|
+
updatedAt: issue.updatedAt ?? null,
|
|
153
|
+
role,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {readonly any[] | undefined} labels
|
|
159
|
+
* @param {Record<string, string>} roles
|
|
160
|
+
* @returns {string | null}
|
|
161
|
+
*/
|
|
162
|
+
function resolveGithubPrdRole(labels, roles) {
|
|
163
|
+
if (!Array.isArray(labels)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const labelNames = new Set(
|
|
168
|
+
labels
|
|
169
|
+
.map(label =>
|
|
170
|
+
typeof label === "string"
|
|
171
|
+
? label
|
|
172
|
+
: typeof label?.name === "string"
|
|
173
|
+
? label.name
|
|
174
|
+
: null
|
|
175
|
+
)
|
|
176
|
+
.filter(Boolean)
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
for (const role of PRD_LIFECYCLE_ORDER) {
|
|
180
|
+
const configuredName = roles[role];
|
|
181
|
+
if (configuredName && labelNames.has(configuredName)) {
|
|
182
|
+
return role;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {Record<string, string> | undefined} roles
|
|
191
|
+
* @returns {Record<string, string>}
|
|
192
|
+
*/
|
|
193
|
+
function normalizeRoles(roles) {
|
|
194
|
+
const normalized = {};
|
|
195
|
+
|
|
196
|
+
for (const role of PRD_LIFECYCLE_ORDER) {
|
|
197
|
+
normalized[role] =
|
|
198
|
+
typeof roles?.[role] === "string" && roles[role].trim().length > 0
|
|
199
|
+
? roles[role].trim()
|
|
200
|
+
: role;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return normalized;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {readonly Record<string, any>[] | undefined} items
|
|
208
|
+
* @returns {readonly Record<string, any>[]}
|
|
209
|
+
*/
|
|
210
|
+
function normalizeItems(items) {
|
|
211
|
+
return (items ?? []).map(normalizeItem).filter(Boolean);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {Record<string, any>} item
|
|
216
|
+
* @returns {Record<string, any> | null}
|
|
217
|
+
*/
|
|
218
|
+
function normalizeItem(item) {
|
|
219
|
+
if (!item || typeof item !== "object") {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const role =
|
|
224
|
+
typeof item.role === "string" && PRD_LIFECYCLE_ORDER.includes(item.role)
|
|
225
|
+
? item.role
|
|
226
|
+
: null;
|
|
227
|
+
|
|
228
|
+
const createdAt = normalizeTimestamp(item.createdAt);
|
|
229
|
+
const updatedAt = normalizeTimestamp(item.updatedAt);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
id: String(item.id ?? item.ref ?? item.title ?? ""),
|
|
233
|
+
ref: String(item.ref ?? item.id ?? item.title ?? ""),
|
|
234
|
+
title: String(item.title ?? "").trim(),
|
|
235
|
+
url: typeof item.url === "string" ? item.url : null,
|
|
236
|
+
role,
|
|
237
|
+
createdAt,
|
|
238
|
+
updatedAt,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {readonly Record<string, any>[]} items
|
|
244
|
+
*/
|
|
245
|
+
function buildLifecycleCounts(items) {
|
|
246
|
+
const counts = Object.fromEntries(PRD_LIFECYCLE_ORDER.map(role => [role, 0]));
|
|
247
|
+
|
|
248
|
+
for (const item of items) {
|
|
249
|
+
if (item.role && counts[item.role] !== undefined) {
|
|
250
|
+
counts[item.role] += 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return counts;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {readonly Record<string, any>[]} items
|
|
259
|
+
* @param {string | undefined} queueArgument
|
|
260
|
+
*/
|
|
261
|
+
function buildActionableHighlights(items, queueArgument) {
|
|
262
|
+
const highlights = [];
|
|
263
|
+
|
|
264
|
+
for (const role of ACTIONABLE_ROLE_ORDER) {
|
|
265
|
+
const oldest = findOldestItemForRole(items, role);
|
|
266
|
+
if (!oldest) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const copy = HIGHLIGHT_COPY[role];
|
|
271
|
+
highlights.push({
|
|
272
|
+
role,
|
|
273
|
+
ref: oldest.ref,
|
|
274
|
+
title: oldest.title,
|
|
275
|
+
url: oldest.url,
|
|
276
|
+
createdAt: oldest.createdAt,
|
|
277
|
+
summary: copy.summary,
|
|
278
|
+
nextStep: expandHighlightNextStep(
|
|
279
|
+
copy.nextStep,
|
|
280
|
+
queueArgument,
|
|
281
|
+
oldest.url
|
|
282
|
+
),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return highlights;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {readonly Record<string, any>[]} items
|
|
291
|
+
* @param {string} role
|
|
292
|
+
*/
|
|
293
|
+
function findOldestItemForRole(items, role) {
|
|
294
|
+
return (
|
|
295
|
+
items
|
|
296
|
+
.filter(item => item.role === role)
|
|
297
|
+
.sort(compareQueueItemsByCreatedAt)[0] ?? null
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {readonly Record<string, any>[]} items
|
|
303
|
+
* @param {Record<string, string>} roles
|
|
304
|
+
* @returns {boolean}
|
|
305
|
+
*/
|
|
306
|
+
function inferNamespaceAdopted(items, roles) {
|
|
307
|
+
if (items.some(item => item.role)) {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return Object.values(roles).some(
|
|
312
|
+
value => typeof value === "string" && value.trim().length > 0
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {string} template
|
|
318
|
+
* @param {string | undefined} queueArgument
|
|
319
|
+
* @param {string | null} itemUrl
|
|
320
|
+
* @returns {string}
|
|
321
|
+
*/
|
|
322
|
+
function expandHighlightNextStep(template, queueArgument, itemUrl) {
|
|
323
|
+
return template
|
|
324
|
+
.replace("<queue>", queueArgument ?? "queue")
|
|
325
|
+
.replace("<item-url>", itemUrl ?? "item URL");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* @param {Record<string, any>} left
|
|
330
|
+
* @param {Record<string, any>} right
|
|
331
|
+
* @returns {number}
|
|
332
|
+
*/
|
|
333
|
+
function compareQueueItemsByCreatedAt(left, right) {
|
|
334
|
+
const leftMs = left.createdAt
|
|
335
|
+
? Date.parse(left.createdAt)
|
|
336
|
+
: Number.POSITIVE_INFINITY;
|
|
337
|
+
const rightMs = right.createdAt
|
|
338
|
+
? Date.parse(right.createdAt)
|
|
339
|
+
: Number.POSITIVE_INFINITY;
|
|
340
|
+
|
|
341
|
+
if (leftMs !== rightMs) {
|
|
342
|
+
return leftMs - rightMs;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return String(left.ref).localeCompare(String(right.ref));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {string | null | undefined} value
|
|
350
|
+
* @returns {string | null}
|
|
351
|
+
*/
|
|
352
|
+
function normalizeTimestamp(value) {
|
|
353
|
+
if (typeof value !== "string") {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const trimmed = value.trim();
|
|
358
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
359
|
+
}
|