mrvn-cli 0.5.5 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2321 -471
- package/dist/index.js.map +1 -1
- package/dist/marvin-serve.js +1837 -380
- package/dist/marvin-serve.js.map +1 -1
- package/dist/marvin.js +2321 -471
- package/dist/marvin.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -15771,6 +15771,7 @@ function collectSprintSummaryData(store, sprintId) {
|
|
|
15771
15771
|
type: doc.frontmatter.type,
|
|
15772
15772
|
status: doc.frontmatter.status,
|
|
15773
15773
|
progress: getEffectiveProgress(doc.frontmatter),
|
|
15774
|
+
owner: doc.frontmatter.owner,
|
|
15774
15775
|
workFocus: focusTag ? focusTag.slice(6) : void 0,
|
|
15775
15776
|
aboutArtifact: about
|
|
15776
15777
|
};
|
|
@@ -15977,10 +15978,10 @@ function getBoardData(store, type) {
|
|
|
15977
15978
|
if (!byStatus.has(status)) byStatus.set(status, []);
|
|
15978
15979
|
byStatus.get(status).push(doc);
|
|
15979
15980
|
}
|
|
15980
|
-
const
|
|
15981
|
+
const statusOrder2 = ["open", "draft", "in-progress", "blocked"];
|
|
15981
15982
|
const allStatuses = [...byStatus.keys()];
|
|
15982
15983
|
const ordered = [];
|
|
15983
|
-
for (const s of
|
|
15984
|
+
for (const s of statusOrder2) {
|
|
15984
15985
|
if (allStatuses.includes(s)) ordered.push(s);
|
|
15985
15986
|
}
|
|
15986
15987
|
for (const s of allStatuses.sort()) {
|
|
@@ -18131,6 +18132,31 @@ tr:hover td {
|
|
|
18131
18132
|
.focus-group-progress {
|
|
18132
18133
|
width: 96px;
|
|
18133
18134
|
}
|
|
18135
|
+
|
|
18136
|
+
/* Owner badges for DM sprint view */
|
|
18137
|
+
.owner-badge {
|
|
18138
|
+
display: inline-block;
|
|
18139
|
+
padding: 0.1rem 0.5rem;
|
|
18140
|
+
border-radius: 999px;
|
|
18141
|
+
font-size: 0.65rem;
|
|
18142
|
+
font-weight: 700;
|
|
18143
|
+
text-transform: uppercase;
|
|
18144
|
+
letter-spacing: 0.04em;
|
|
18145
|
+
white-space: nowrap;
|
|
18146
|
+
}
|
|
18147
|
+
.owner-badge-po { background: rgba(108, 140, 255, 0.18); color: #6c8cff; }
|
|
18148
|
+
.owner-badge-tl { background: rgba(251, 191, 36, 0.18); color: #fbbf24; }
|
|
18149
|
+
.owner-badge-dm { background: rgba(52, 211, 153, 0.18); color: #34d399; }
|
|
18150
|
+
.owner-badge-other { background: rgba(139, 143, 164, 0.12); color: var(--text-dim); }
|
|
18151
|
+
|
|
18152
|
+
/* Group header rows (PO dashboard decisions/deps) */
|
|
18153
|
+
.group-header-row td {
|
|
18154
|
+
background: var(--bg-hover);
|
|
18155
|
+
padding-top: 0.5rem;
|
|
18156
|
+
padding-bottom: 0.5rem;
|
|
18157
|
+
border-bottom: 1px solid var(--border);
|
|
18158
|
+
font-size: 0.8rem;
|
|
18159
|
+
}
|
|
18134
18160
|
`;
|
|
18135
18161
|
}
|
|
18136
18162
|
|
|
@@ -18962,19 +18988,55 @@ function buildHealthGauge(categories) {
|
|
|
18962
18988
|
}
|
|
18963
18989
|
|
|
18964
18990
|
// src/web/templates/pages/po/dashboard.ts
|
|
18991
|
+
var DONE_STATUSES5 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
18992
|
+
var RESOLVED_DECISION_STATUSES = /* @__PURE__ */ new Set(["decided", "superseded", "dismissed"]);
|
|
18965
18993
|
function poDashboardPage(ctx) {
|
|
18966
18994
|
const overview = getOverviewData(ctx.store);
|
|
18967
|
-
const upcoming = getUpcomingData(ctx.store);
|
|
18968
18995
|
const sprintData = getSprintSummaryData(ctx.store);
|
|
18969
18996
|
const diagrams = getDiagramData(ctx.store);
|
|
18970
18997
|
const features = ctx.store.list({ type: "feature" });
|
|
18971
|
-
const
|
|
18972
|
-
const
|
|
18973
|
-
const featuresInProgress = features.filter((d) => d.frontmatter.status === "in-progress").length;
|
|
18974
|
-
const RESOLVED_DECISION_STATUSES2 = /* @__PURE__ */ new Set(["decided", "superseded", "dismissed"]);
|
|
18998
|
+
const epics = ctx.store.list({ type: "epic" });
|
|
18999
|
+
const allTasks = ctx.store.list({ type: "task" });
|
|
18975
19000
|
const decisions = ctx.store.list({ type: "decision" });
|
|
18976
|
-
const decisionsOpen = decisions.filter((d) => !RESOLVED_DECISION_STATUSES2.has(d.frontmatter.status)).length;
|
|
18977
19001
|
const questions = ctx.store.list({ type: "question" });
|
|
19002
|
+
const sprints = ctx.store.list({ type: "sprint" });
|
|
19003
|
+
const featureToEpics = /* @__PURE__ */ new Map();
|
|
19004
|
+
for (const epic of epics) {
|
|
19005
|
+
const featureIds = normalizeLinkedFeatures(epic.frontmatter.linkedFeature);
|
|
19006
|
+
for (const fid of featureIds) {
|
|
19007
|
+
const arr = featureToEpics.get(fid) ?? [];
|
|
19008
|
+
arr.push(epic);
|
|
19009
|
+
featureToEpics.set(fid, arr);
|
|
19010
|
+
}
|
|
19011
|
+
}
|
|
19012
|
+
const epicToTasks = /* @__PURE__ */ new Map();
|
|
19013
|
+
for (const task of allTasks) {
|
|
19014
|
+
const tags = task.frontmatter.tags ?? [];
|
|
19015
|
+
for (const tag of tags) {
|
|
19016
|
+
if (tag.startsWith("epic:")) {
|
|
19017
|
+
const epicId = tag.slice(5);
|
|
19018
|
+
const arr = epicToTasks.get(epicId) ?? [];
|
|
19019
|
+
arr.push(task);
|
|
19020
|
+
epicToTasks.set(epicId, arr);
|
|
19021
|
+
}
|
|
19022
|
+
}
|
|
19023
|
+
}
|
|
19024
|
+
const activeSprint = sprints.find((s) => s.frontmatter.status === "active");
|
|
19025
|
+
let sprintTimelinePct = 0;
|
|
19026
|
+
if (activeSprint) {
|
|
19027
|
+
const startDate = activeSprint.frontmatter.startDate;
|
|
19028
|
+
const endDate = activeSprint.frontmatter.endDate;
|
|
19029
|
+
if (startDate && endDate) {
|
|
19030
|
+
const startMs = new Date(startDate).getTime();
|
|
19031
|
+
const endMs = new Date(endDate).getTime();
|
|
19032
|
+
const totalDays = Math.max(1, endMs - startMs);
|
|
19033
|
+
sprintTimelinePct = Math.min(100, Math.max(0, Math.round((Date.now() - startMs) / totalDays * 100)));
|
|
19034
|
+
}
|
|
19035
|
+
}
|
|
19036
|
+
const featuresDone = features.filter((d) => DONE_STATUSES5.has(d.frontmatter.status)).length;
|
|
19037
|
+
const featuresOpen = features.filter((d) => d.frontmatter.status === "open").length;
|
|
19038
|
+
const featuresInProgress = features.filter((d) => d.frontmatter.status === "in-progress").length;
|
|
19039
|
+
const decisionsOpen = decisions.filter((d) => !RESOLVED_DECISION_STATUSES.has(d.frontmatter.status)).length;
|
|
18978
19040
|
const questionsOpen = questions.filter((d) => d.frontmatter.status === "open").length;
|
|
18979
19041
|
const statsCards = `
|
|
18980
19042
|
<div class="cards">
|
|
@@ -19001,7 +19063,7 @@ function poDashboardPage(ctx) {
|
|
|
19001
19063
|
</div>
|
|
19002
19064
|
<div class="card">
|
|
19003
19065
|
<a href="/po/delivery">
|
|
19004
|
-
<div class="card-label">Sprint</div>
|
|
19066
|
+
<div class="card-label">Current Sprint</div>
|
|
19005
19067
|
<div class="card-value">${sprintData ? `${sprintData.workItems.completionPct}%` : "\u2014"}</div>
|
|
19006
19068
|
<div class="card-sub">${sprintData ? `${sprintData.workItems.done}/${sprintData.workItems.total} items` : "No active sprint"}</div>
|
|
19007
19069
|
</a>
|
|
@@ -19037,43 +19099,72 @@ function poDashboardPage(ctx) {
|
|
|
19037
19099
|
</div>`,
|
|
19038
19100
|
{ titleTag: "h3" }
|
|
19039
19101
|
) : "";
|
|
19040
|
-
|
|
19041
|
-
|
|
19042
|
-
|
|
19043
|
-
|
|
19044
|
-
|
|
19045
|
-
|
|
19046
|
-
|
|
19047
|
-
|
|
19102
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
19103
|
+
const atRiskItems = [];
|
|
19104
|
+
for (const f of features) {
|
|
19105
|
+
if (DONE_STATUSES5.has(f.frontmatter.status)) continue;
|
|
19106
|
+
const fEpics = featureToEpics.get(f.frontmatter.id) ?? [];
|
|
19107
|
+
const reasons = [];
|
|
19108
|
+
let blocked = 0;
|
|
19109
|
+
for (const epic of fEpics) {
|
|
19110
|
+
for (const t of epicToTasks.get(epic.frontmatter.id) ?? []) {
|
|
19111
|
+
if (t.frontmatter.status === "blocked") blocked++;
|
|
19112
|
+
}
|
|
19113
|
+
}
|
|
19114
|
+
if (blocked > 0) reasons.push(`${blocked} blocked task${blocked > 1 ? "s" : ""}`);
|
|
19115
|
+
for (const epic of fEpics) {
|
|
19116
|
+
const td = epic.frontmatter.targetDate;
|
|
19117
|
+
if (td && td < today && !DONE_STATUSES5.has(epic.frontmatter.status)) {
|
|
19118
|
+
reasons.push(`${epic.frontmatter.id} overdue`);
|
|
19119
|
+
}
|
|
19120
|
+
}
|
|
19121
|
+
let totalTasks = 0;
|
|
19122
|
+
let progressSum = 0;
|
|
19123
|
+
for (const epic of fEpics) {
|
|
19124
|
+
for (const t of epicToTasks.get(epic.frontmatter.id) ?? []) {
|
|
19125
|
+
totalTasks++;
|
|
19126
|
+
progressSum += getEffectiveProgress(t.frontmatter);
|
|
19127
|
+
}
|
|
19128
|
+
}
|
|
19129
|
+
const avgProgress = totalTasks > 0 ? Math.round(progressSum / totalTasks) : 0;
|
|
19130
|
+
if (avgProgress < 30 && sprintTimelinePct > 60 && totalTasks > 0) {
|
|
19131
|
+
reasons.push("Low progress vs sprint timeline");
|
|
19132
|
+
}
|
|
19133
|
+
if (reasons.length > 0) atRiskItems.push({ feature: f, reasons });
|
|
19134
|
+
}
|
|
19135
|
+
const atRiskSection = atRiskItems.length > 0 ? collapsibleSection(
|
|
19136
|
+
"po-at-risk",
|
|
19137
|
+
`At-Risk Delivery (${atRiskItems.length})`,
|
|
19048
19138
|
`<div class="table-wrap">
|
|
19049
19139
|
<table>
|
|
19050
19140
|
<thead>
|
|
19051
|
-
<tr><th>
|
|
19141
|
+
<tr><th>Feature</th><th>Risk Reasons</th></tr>
|
|
19052
19142
|
</thead>
|
|
19053
19143
|
<tbody>
|
|
19054
|
-
${
|
|
19055
|
-
const tags = t.signals.map((s) => `<span class="${signalTagClass(s.points)}" title="${escapeHtml(s.factor)}: +${s.points}pts">${escapeHtml(s.factor)}</span>`).join("");
|
|
19056
|
-
return `
|
|
19144
|
+
${atRiskItems.map((r) => `
|
|
19057
19145
|
<tr>
|
|
19058
|
-
<td><a href="/docs/${escapeHtml(
|
|
19059
|
-
<td>${
|
|
19060
|
-
|
|
19061
|
-
</tr>`;
|
|
19062
|
-
}).join("")}
|
|
19146
|
+
<td><a href="/docs/feature/${escapeHtml(r.feature.frontmatter.id)}">${escapeHtml(r.feature.frontmatter.id)}</a> ${escapeHtml(r.feature.frontmatter.title)}</td>
|
|
19147
|
+
<td>${r.reasons.map((reason) => `<span class="signal-tag signal-tag-high">${escapeHtml(reason)}</span>`).join(" ")}</td>
|
|
19148
|
+
</tr>`).join("")}
|
|
19063
19149
|
</tbody>
|
|
19064
19150
|
</table>
|
|
19065
19151
|
</div>`,
|
|
19066
19152
|
{ titleTag: "h3" }
|
|
19067
|
-
) :
|
|
19153
|
+
) : collapsibleSection(
|
|
19154
|
+
"po-at-risk",
|
|
19155
|
+
"At-Risk Delivery",
|
|
19156
|
+
'<div class="empty"><p style="color: var(--green);">No at-risk items \u2014 all features on track.</p></div>',
|
|
19157
|
+
{ titleTag: "h3", defaultCollapsed: true }
|
|
19158
|
+
);
|
|
19068
19159
|
return `
|
|
19069
19160
|
<div class="page-header">
|
|
19070
19161
|
<h2>Product Owner Dashboard</h2>
|
|
19071
19162
|
<div class="subtitle">Feature delivery, decisions, and stakeholder alignment</div>
|
|
19072
19163
|
</div>
|
|
19073
19164
|
${statsCards}
|
|
19165
|
+
${atRiskSection}
|
|
19074
19166
|
${diagramSection}
|
|
19075
19167
|
${recentTable}
|
|
19076
|
-
${trendingSection}
|
|
19077
19168
|
`;
|
|
19078
19169
|
}
|
|
19079
19170
|
|
|
@@ -19252,44 +19343,74 @@ function tableDateFilter(tableId, colIndex) {
|
|
|
19252
19343
|
}
|
|
19253
19344
|
|
|
19254
19345
|
// src/web/templates/pages/po/backlog.ts
|
|
19346
|
+
function priorityClass(p) {
|
|
19347
|
+
if (!p) return "";
|
|
19348
|
+
const lower = p.toLowerCase();
|
|
19349
|
+
if (lower === "critical" || lower === "high") return " priority-high";
|
|
19350
|
+
if (lower === "medium") return " priority-medium";
|
|
19351
|
+
if (lower === "low") return " priority-low";
|
|
19352
|
+
return "";
|
|
19353
|
+
}
|
|
19354
|
+
function miniProgressBar(pct) {
|
|
19355
|
+
return `<div class="mini-progress-bar"><div class="mini-progress-fill" style="width:${pct}%"></div><span class="mini-progress-label">${pct}%</span></div>`;
|
|
19356
|
+
}
|
|
19255
19357
|
function poBacklogPage(ctx) {
|
|
19256
19358
|
const features = ctx.store.list({ type: "feature" });
|
|
19257
19359
|
const questions = ctx.store.list({ type: "question" });
|
|
19258
19360
|
const openQuestions = questions.filter((d) => d.frontmatter.status === "open");
|
|
19259
|
-
const
|
|
19260
|
-
const
|
|
19361
|
+
const priorityOrder2 = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
19362
|
+
const statusOrder2 = { "in-progress": 0, open: 1, draft: 2, blocked: 3, done: 4, closed: 5, resolved: 6 };
|
|
19261
19363
|
const sortedFeatures = [...features].sort((a, b) => {
|
|
19262
|
-
const sa =
|
|
19263
|
-
const sb =
|
|
19364
|
+
const sa = statusOrder2[a.frontmatter.status] ?? 3;
|
|
19365
|
+
const sb = statusOrder2[b.frontmatter.status] ?? 3;
|
|
19264
19366
|
if (sa !== sb) return sa - sb;
|
|
19265
|
-
const pa =
|
|
19266
|
-
const pb =
|
|
19367
|
+
const pa = priorityOrder2[a.frontmatter.priority?.toLowerCase()] ?? 99;
|
|
19368
|
+
const pb = priorityOrder2[b.frontmatter.priority?.toLowerCase()] ?? 99;
|
|
19267
19369
|
if (pa !== pb) return pa - pb;
|
|
19268
19370
|
return a.frontmatter.id.localeCompare(b.frontmatter.id);
|
|
19269
19371
|
});
|
|
19270
19372
|
const epics = ctx.store.list({ type: "epic" });
|
|
19271
19373
|
const featureToEpics = /* @__PURE__ */ new Map();
|
|
19272
19374
|
for (const epic of epics) {
|
|
19273
|
-
const
|
|
19274
|
-
const featureIds = Array.isArray(linked) ? linked : linked ? [linked] : [];
|
|
19375
|
+
const featureIds = normalizeLinkedFeatures(epic.frontmatter.linkedFeature);
|
|
19275
19376
|
for (const fid of featureIds) {
|
|
19276
|
-
const
|
|
19277
|
-
|
|
19278
|
-
featureToEpics.set(
|
|
19377
|
+
const arr = featureToEpics.get(fid) ?? [];
|
|
19378
|
+
arr.push(epic);
|
|
19379
|
+
featureToEpics.set(fid, arr);
|
|
19279
19380
|
}
|
|
19280
19381
|
}
|
|
19281
|
-
|
|
19282
|
-
|
|
19283
|
-
|
|
19284
|
-
|
|
19285
|
-
|
|
19286
|
-
|
|
19287
|
-
|
|
19382
|
+
const allTasks = ctx.store.list({ type: "task" });
|
|
19383
|
+
const epicToTasks = /* @__PURE__ */ new Map();
|
|
19384
|
+
for (const task of allTasks) {
|
|
19385
|
+
const tags = task.frontmatter.tags ?? [];
|
|
19386
|
+
for (const tag of tags) {
|
|
19387
|
+
if (tag.startsWith("epic:")) {
|
|
19388
|
+
const epicId = tag.slice(5);
|
|
19389
|
+
const arr = epicToTasks.get(epicId) ?? [];
|
|
19390
|
+
arr.push(task);
|
|
19391
|
+
epicToTasks.set(epicId, arr);
|
|
19392
|
+
}
|
|
19393
|
+
}
|
|
19394
|
+
}
|
|
19395
|
+
const DONE_STATUSES16 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
19396
|
+
function featureTaskStats(featureId) {
|
|
19397
|
+
const fEpics = featureToEpics.get(featureId) ?? [];
|
|
19398
|
+
let total = 0;
|
|
19399
|
+
let done = 0;
|
|
19400
|
+
let progressSum = 0;
|
|
19401
|
+
for (const epic of fEpics) {
|
|
19402
|
+
for (const t of epicToTasks.get(epic.frontmatter.id) ?? []) {
|
|
19403
|
+
total++;
|
|
19404
|
+
if (DONE_STATUSES16.has(t.frontmatter.status)) done++;
|
|
19405
|
+
progressSum += getEffectiveProgress(t.frontmatter);
|
|
19406
|
+
}
|
|
19407
|
+
}
|
|
19408
|
+
return { epicCount: fEpics.length, total, done, avgProgress: total > 0 ? Math.round(progressSum / total) : 0 };
|
|
19288
19409
|
}
|
|
19289
19410
|
const featureStatuses = [...new Set(features.map((d) => d.frontmatter.status))].sort();
|
|
19290
19411
|
const featurePriorities = [...new Set(features.map((d) => d.frontmatter.priority ?? "").filter(Boolean))].sort();
|
|
19291
19412
|
const featureEpicIds = [...new Set(
|
|
19292
|
-
features.flatMap((d) => featureToEpics.get(d.frontmatter.id) ?? [])
|
|
19413
|
+
features.flatMap((d) => (featureToEpics.get(d.frontmatter.id) ?? []).map((e) => e.frontmatter.id))
|
|
19293
19414
|
)].sort();
|
|
19294
19415
|
const featuresFilters = `<div class="filters">
|
|
19295
19416
|
${tableFilter("features-table", 2, "Status", featureStatuses)}
|
|
@@ -19300,12 +19421,13 @@ function poBacklogPage(ctx) {
|
|
|
19300
19421
|
<div class="table-wrap table-short">
|
|
19301
19422
|
<table id="features-table">
|
|
19302
19423
|
<thead>
|
|
19303
|
-
<tr>${sortableTh("ID", "features-table", 0)}${sortableTh("Title", "features-table", 1)}${sortableTh("Status", "features-table", 2)}${sortableTh("Priority", "features-table", 3)}<th>
|
|
19424
|
+
<tr>${sortableTh("ID", "features-table", 0)}${sortableTh("Title", "features-table", 1)}${sortableTh("Status", "features-table", 2)}${sortableTh("Priority", "features-table", 3)}<th>Epics</th><th>Tasks</th><th>Progress</th></tr>
|
|
19304
19425
|
</thead>
|
|
19305
19426
|
<tbody>
|
|
19306
19427
|
${sortedFeatures.map((d) => {
|
|
19307
|
-
const
|
|
19308
|
-
const
|
|
19428
|
+
const stats = featureTaskStats(d.frontmatter.id);
|
|
19429
|
+
const linkedEpicDocs = featureToEpics.get(d.frontmatter.id) ?? [];
|
|
19430
|
+
const epicLinks = linkedEpicDocs.map((e) => `<a href="/docs/epic/${escapeHtml(e.frontmatter.id)}">${escapeHtml(e.frontmatter.id)}</a>`).join(", ");
|
|
19309
19431
|
return `
|
|
19310
19432
|
<tr>
|
|
19311
19433
|
<td><a href="/docs/feature/${escapeHtml(d.frontmatter.id)}">${escapeHtml(d.frontmatter.id)}</a></td>
|
|
@@ -19313,6 +19435,8 @@ function poBacklogPage(ctx) {
|
|
|
19313
19435
|
<td>${statusBadge(d.frontmatter.status)}</td>
|
|
19314
19436
|
<td><span class="${priorityClass(d.frontmatter.priority)}">${escapeHtml(d.frontmatter.priority ?? "\u2014")}</span></td>
|
|
19315
19437
|
<td>${epicLinks || '<span class="text-dim">\u2014</span>'}</td>
|
|
19438
|
+
<td>${stats.total > 0 ? `${stats.done}/${stats.total}` : '<span class="text-dim">\u2014</span>'}</td>
|
|
19439
|
+
<td>${stats.total > 0 ? miniProgressBar(stats.avgProgress) : '<span class="text-dim">\u2014</span>'}</td>
|
|
19316
19440
|
</tr>`;
|
|
19317
19441
|
}).join("")}
|
|
19318
19442
|
</tbody>
|
|
@@ -19354,14 +19478,23 @@ function poBacklogPage(ctx) {
|
|
|
19354
19478
|
|
|
19355
19479
|
// src/web/templates/pages/po/decisions.ts
|
|
19356
19480
|
var RESOLVED_STATUSES = /* @__PURE__ */ new Set(["decided", "superseded", "dismissed"]);
|
|
19481
|
+
var KNOWN_OWNERS = /* @__PURE__ */ new Set(["po", "tl", "dm"]);
|
|
19482
|
+
function ownerBadge(owner) {
|
|
19483
|
+
if (!owner) return '<span class="text-dim">\u2014</span>';
|
|
19484
|
+
const cls = KNOWN_OWNERS.has(owner.toLowerCase()) ? `owner-badge-${owner.toLowerCase()}` : "owner-badge-other";
|
|
19485
|
+
return `<span class="owner-badge ${cls}">${escapeHtml(owner.toUpperCase())}</span>`;
|
|
19486
|
+
}
|
|
19357
19487
|
function poDecisionsPage(ctx) {
|
|
19358
19488
|
const decisions = ctx.store.list({ type: "decision" });
|
|
19489
|
+
const questions = ctx.store.list({ type: "question" });
|
|
19490
|
+
const features = ctx.store.list({ type: "feature" });
|
|
19359
19491
|
const openDecisions = decisions.filter((d) => !RESOLVED_STATUSES.has(d.frontmatter.status));
|
|
19360
19492
|
const resolvedDecisions = decisions.filter((d) => RESOLVED_STATUSES.has(d.frontmatter.status));
|
|
19493
|
+
const openQuestions = questions.filter((d) => d.frontmatter.status === "open");
|
|
19361
19494
|
const statsCards = `
|
|
19362
19495
|
<div class="cards">
|
|
19363
19496
|
<div class="card">
|
|
19364
|
-
<div class="card-label">Open</div>
|
|
19497
|
+
<div class="card-label">Open Decisions</div>
|
|
19365
19498
|
<div class="card-value${openDecisions.length > 0 ? " priority-medium" : ""}">${openDecisions.length}</div>
|
|
19366
19499
|
<div class="card-sub">awaiting resolution</div>
|
|
19367
19500
|
</div>
|
|
@@ -19370,12 +19503,79 @@ function poDecisionsPage(ctx) {
|
|
|
19370
19503
|
<div class="card-value">${resolvedDecisions.length}</div>
|
|
19371
19504
|
<div class="card-sub">decisions made</div>
|
|
19372
19505
|
</div>
|
|
19506
|
+
<div class="card">
|
|
19507
|
+
<div class="card-label">Open Questions</div>
|
|
19508
|
+
<div class="card-value${openQuestions.length > 0 ? " priority-medium" : ""}">${openQuestions.length}</div>
|
|
19509
|
+
<div class="card-sub">needing answers</div>
|
|
19510
|
+
</div>
|
|
19373
19511
|
<div class="card">
|
|
19374
19512
|
<div class="card-label">Total</div>
|
|
19375
19513
|
<div class="card-value">${decisions.length}</div>
|
|
19376
19514
|
<div class="card-sub">all decisions</div>
|
|
19377
19515
|
</div>
|
|
19378
19516
|
</div>`;
|
|
19517
|
+
function daysSince(isoDate) {
|
|
19518
|
+
if (!isoDate) return 0;
|
|
19519
|
+
return Math.max(0, Math.floor((Date.now() - new Date(isoDate).getTime()) / 864e5));
|
|
19520
|
+
}
|
|
19521
|
+
const featureGroups = /* @__PURE__ */ new Map();
|
|
19522
|
+
const unlinked = [];
|
|
19523
|
+
function addToGroup(doc, docType) {
|
|
19524
|
+
const tags = doc.frontmatter.tags ?? [];
|
|
19525
|
+
const featureTags = tags.filter((t) => t.startsWith("feature:")).map((t) => t.slice(8));
|
|
19526
|
+
const item = { doc, docType, ageDays: daysSince(doc.frontmatter.created) };
|
|
19527
|
+
if (featureTags.length === 0) {
|
|
19528
|
+
unlinked.push(item);
|
|
19529
|
+
} else {
|
|
19530
|
+
for (const fid of featureTags) {
|
|
19531
|
+
const arr = featureGroups.get(fid) ?? [];
|
|
19532
|
+
arr.push(item);
|
|
19533
|
+
featureGroups.set(fid, arr);
|
|
19534
|
+
}
|
|
19535
|
+
}
|
|
19536
|
+
}
|
|
19537
|
+
for (const d of openDecisions) addToGroup(d, "decision");
|
|
19538
|
+
for (const q of openQuestions) addToGroup(q, "question");
|
|
19539
|
+
const totalDeps = openDecisions.length + openQuestions.length;
|
|
19540
|
+
const featureLookup = new Map(features.map((f) => [f.frontmatter.id, f]));
|
|
19541
|
+
function renderDepRows(items) {
|
|
19542
|
+
return items.map((item) => `
|
|
19543
|
+
<tr>
|
|
19544
|
+
<td><a href="/docs/${item.docType}/${escapeHtml(item.doc.frontmatter.id)}">${escapeHtml(item.doc.frontmatter.id)}</a></td>
|
|
19545
|
+
<td>${escapeHtml(item.doc.frontmatter.title)}</td>
|
|
19546
|
+
<td>${escapeHtml(typeLabel(item.docType))}</td>
|
|
19547
|
+
<td>${ownerBadge(item.doc.frontmatter.owner)}</td>
|
|
19548
|
+
<td>${item.ageDays}d</td>
|
|
19549
|
+
</tr>`).join("");
|
|
19550
|
+
}
|
|
19551
|
+
let depRows = "";
|
|
19552
|
+
for (const [fid, items] of featureGroups) {
|
|
19553
|
+
const feat = featureLookup.get(fid);
|
|
19554
|
+
const label = feat ? `${escapeHtml(fid)}: ${escapeHtml(feat.frontmatter.title)}` : escapeHtml(fid);
|
|
19555
|
+
depRows += `
|
|
19556
|
+
<tr class="group-header-row"><td colspan="5"><strong>${label}</strong></td></tr>
|
|
19557
|
+
${renderDepRows(items)}`;
|
|
19558
|
+
}
|
|
19559
|
+
if (unlinked.length > 0) {
|
|
19560
|
+
depRows += `
|
|
19561
|
+
<tr class="group-header-row"><td colspan="5"><strong>Unlinked</strong></td></tr>
|
|
19562
|
+
${renderDepRows(unlinked)}`;
|
|
19563
|
+
}
|
|
19564
|
+
const depsSection = totalDeps > 0 ? collapsibleSection(
|
|
19565
|
+
"po-decisions-deps",
|
|
19566
|
+
`By Feature (${totalDeps})`,
|
|
19567
|
+
`<div class="table-wrap">
|
|
19568
|
+
<table>
|
|
19569
|
+
<thead>
|
|
19570
|
+
<tr><th>ID</th><th>Title</th><th>Type</th><th>Owner</th><th>Age</th></tr>
|
|
19571
|
+
</thead>
|
|
19572
|
+
<tbody>
|
|
19573
|
+
${depRows}
|
|
19574
|
+
</tbody>
|
|
19575
|
+
</table>
|
|
19576
|
+
</div>`,
|
|
19577
|
+
{ titleTag: "h3" }
|
|
19578
|
+
) : "";
|
|
19379
19579
|
function decisionTable(docs, tableId) {
|
|
19380
19580
|
if (docs.length === 0) return '<div class="empty"><p>None found.</p></div>';
|
|
19381
19581
|
const statuses = [...new Set(docs.map((d) => d.frontmatter.status))].sort();
|
|
@@ -19397,7 +19597,7 @@ function poDecisionsPage(ctx) {
|
|
|
19397
19597
|
<td><a href="/docs/decision/${escapeHtml(d.frontmatter.id)}">${escapeHtml(d.frontmatter.id)}</a></td>
|
|
19398
19598
|
<td>${escapeHtml(d.frontmatter.title)}</td>
|
|
19399
19599
|
<td>${statusBadge(d.frontmatter.status)}</td>
|
|
19400
|
-
<td>${
|
|
19600
|
+
<td>${ownerBadge(d.frontmatter.owner)}</td>
|
|
19401
19601
|
<td>${formatDate(d.frontmatter.created)}</td>
|
|
19402
19602
|
</tr>`).join("")}
|
|
19403
19603
|
</tbody>
|
|
@@ -19419,16 +19619,185 @@ function poDecisionsPage(ctx) {
|
|
|
19419
19619
|
return `
|
|
19420
19620
|
<div class="page-header">
|
|
19421
19621
|
<h2>Decision Log</h2>
|
|
19422
|
-
<div class="subtitle">Track and manage product decisions</div>
|
|
19622
|
+
<div class="subtitle">Track and manage product decisions and dependencies</div>
|
|
19423
19623
|
</div>
|
|
19424
19624
|
${statsCards}
|
|
19625
|
+
${depsSection}
|
|
19425
19626
|
${openSection}
|
|
19426
19627
|
${resolvedSection}
|
|
19427
19628
|
${renderTableUtilsScript()}
|
|
19428
19629
|
`;
|
|
19429
19630
|
}
|
|
19430
19631
|
|
|
19632
|
+
// src/web/templates/components/work-items-table.ts
|
|
19633
|
+
var FOCUS_BORDER_PALETTE = [
|
|
19634
|
+
"hsl(220, 60%, 55%)",
|
|
19635
|
+
"hsl(160, 50%, 45%)",
|
|
19636
|
+
"hsl(280, 45%, 55%)",
|
|
19637
|
+
"hsl(30, 65%, 55%)",
|
|
19638
|
+
"hsl(340, 50%, 55%)",
|
|
19639
|
+
"hsl(190, 50%, 45%)",
|
|
19640
|
+
"hsl(60, 50%, 50%)",
|
|
19641
|
+
"hsl(120, 40%, 45%)"
|
|
19642
|
+
];
|
|
19643
|
+
function hashString(s) {
|
|
19644
|
+
let h = 0;
|
|
19645
|
+
for (let i = 0; i < s.length; i++) {
|
|
19646
|
+
h = (h << 5) - h + s.charCodeAt(i) | 0;
|
|
19647
|
+
}
|
|
19648
|
+
return Math.abs(h);
|
|
19649
|
+
}
|
|
19650
|
+
function countFocusStats(items) {
|
|
19651
|
+
let total = 0;
|
|
19652
|
+
let done = 0;
|
|
19653
|
+
let inProgress = 0;
|
|
19654
|
+
function walk(list) {
|
|
19655
|
+
for (const w of list) {
|
|
19656
|
+
if (w.type !== "contribution") {
|
|
19657
|
+
total++;
|
|
19658
|
+
const s = w.status.toLowerCase();
|
|
19659
|
+
if (s === "done" || s === "closed" || s === "resolved" || s === "decided") done++;
|
|
19660
|
+
else if (s === "in-progress" || s === "in progress") inProgress++;
|
|
19661
|
+
}
|
|
19662
|
+
if (w.children) walk(w.children);
|
|
19663
|
+
}
|
|
19664
|
+
}
|
|
19665
|
+
walk(items);
|
|
19666
|
+
return { total, done, inProgress };
|
|
19667
|
+
}
|
|
19668
|
+
var KNOWN_OWNERS2 = /* @__PURE__ */ new Set(["po", "tl", "dm"]);
|
|
19669
|
+
function ownerBadge2(owner) {
|
|
19670
|
+
if (!owner) return '<span class="text-dim">\u2014</span>';
|
|
19671
|
+
const cls = KNOWN_OWNERS2.has(owner) ? `owner-badge-${owner}` : "owner-badge-other";
|
|
19672
|
+
return `<span class="owner-badge ${cls}">${escapeHtml(owner.toUpperCase())}</span>`;
|
|
19673
|
+
}
|
|
19674
|
+
function renderItemRows(items, borderColor, showOwner, depth = 0) {
|
|
19675
|
+
return items.flatMap((w) => {
|
|
19676
|
+
const isChild = depth > 0;
|
|
19677
|
+
const isContribution = w.type === "contribution";
|
|
19678
|
+
const classes = ["focus-row"];
|
|
19679
|
+
if (isContribution) classes.push("contribution-row");
|
|
19680
|
+
else if (isChild) classes.push("child-row");
|
|
19681
|
+
const indent = depth > 0 ? ` style="padding-left: ${0.75 + depth * 1}rem"` : "";
|
|
19682
|
+
const progressCell = !isContribution && w.progress !== void 0 ? `<div class="mini-progress-bar"><div class="mini-progress-fill" style="width:${w.progress}%"></div><span class="mini-progress-label">${w.progress}%</span></div>` : "";
|
|
19683
|
+
const ownerCell = showOwner ? `<td>${ownerBadge2(w.owner)}</td>` : "";
|
|
19684
|
+
const row = `
|
|
19685
|
+
<tr class="${classes.join(" ")}" style="--focus-color: ${borderColor}">
|
|
19686
|
+
<td${indent}><a href="/docs/${escapeHtml(w.type)}/${escapeHtml(w.id)}">${escapeHtml(w.id)}</a></td>
|
|
19687
|
+
<td>${escapeHtml(w.title)}</td>
|
|
19688
|
+
${ownerCell}
|
|
19689
|
+
<td>${statusBadge(w.status)}</td>
|
|
19690
|
+
<td>${progressCell}</td>
|
|
19691
|
+
</tr>`;
|
|
19692
|
+
const childRows = w.children ? renderItemRows(w.children, borderColor, showOwner, depth + 1) : [];
|
|
19693
|
+
return [row, ...childRows];
|
|
19694
|
+
});
|
|
19695
|
+
}
|
|
19696
|
+
function renderWorkItemsTable(items, options) {
|
|
19697
|
+
const sectionId = options?.sectionId ?? "work-items";
|
|
19698
|
+
const title = options?.title ?? "Work Items";
|
|
19699
|
+
const defaultCollapsed = options?.defaultCollapsed ?? false;
|
|
19700
|
+
const showOwner = options?.showOwner ?? false;
|
|
19701
|
+
const focusGroups = /* @__PURE__ */ new Map();
|
|
19702
|
+
for (const item of items) {
|
|
19703
|
+
const focus = item.workFocus ?? "Unassigned";
|
|
19704
|
+
if (!focusGroups.has(focus)) focusGroups.set(focus, []);
|
|
19705
|
+
focusGroups.get(focus).push(item);
|
|
19706
|
+
}
|
|
19707
|
+
const focusColorMap = /* @__PURE__ */ new Map();
|
|
19708
|
+
for (const name of focusGroups.keys()) {
|
|
19709
|
+
focusColorMap.set(name, FOCUS_BORDER_PALETTE[hashString(name) % FOCUS_BORDER_PALETTE.length]);
|
|
19710
|
+
}
|
|
19711
|
+
const allWorkItemRows = [];
|
|
19712
|
+
for (const [focus, groupItems] of focusGroups) {
|
|
19713
|
+
const color = focusColorMap.get(focus);
|
|
19714
|
+
const stats = countFocusStats(groupItems);
|
|
19715
|
+
const pct = stats.total > 0 ? Math.round(stats.done / stats.total * 100) : 0;
|
|
19716
|
+
const summaryParts = [];
|
|
19717
|
+
if (stats.done > 0) summaryParts.push(`${stats.done} done`);
|
|
19718
|
+
if (stats.inProgress > 0) summaryParts.push(`${stats.inProgress} in progress`);
|
|
19719
|
+
const remaining = stats.total - stats.done - stats.inProgress;
|
|
19720
|
+
if (remaining > 0) summaryParts.push(`${remaining} open`);
|
|
19721
|
+
const leftColspan = showOwner ? 3 : 2;
|
|
19722
|
+
allWorkItemRows.push(`
|
|
19723
|
+
<tr class="focus-group-header" style="--focus-color: ${color}">
|
|
19724
|
+
<td colspan="${leftColspan}">
|
|
19725
|
+
<span class="focus-group-name">${escapeHtml(focus)}</span>
|
|
19726
|
+
<span class="focus-group-stats">${summaryParts.join(" / ")}</span>
|
|
19727
|
+
</td>
|
|
19728
|
+
<td colspan="2">
|
|
19729
|
+
<div class="mini-progress-bar focus-group-progress"><div class="mini-progress-fill" style="width:${pct}%"></div><span class="mini-progress-label">${pct}%</span></div>
|
|
19730
|
+
</td>
|
|
19731
|
+
</tr>`);
|
|
19732
|
+
allWorkItemRows.push(...renderItemRows(groupItems, color, showOwner));
|
|
19733
|
+
}
|
|
19734
|
+
if (allWorkItemRows.length === 0) return "";
|
|
19735
|
+
const ownerHeader = showOwner ? "<th>Owner</th>" : "";
|
|
19736
|
+
return collapsibleSection(
|
|
19737
|
+
sectionId,
|
|
19738
|
+
title,
|
|
19739
|
+
`<div class="table-wrap">
|
|
19740
|
+
<table id="${sectionId}-table">
|
|
19741
|
+
<thead>
|
|
19742
|
+
<tr>
|
|
19743
|
+
<th>ID</th>
|
|
19744
|
+
<th>Title</th>
|
|
19745
|
+
${ownerHeader}
|
|
19746
|
+
<th>Status</th>
|
|
19747
|
+
<th>Progress</th>
|
|
19748
|
+
</tr>
|
|
19749
|
+
</thead>
|
|
19750
|
+
<tbody>
|
|
19751
|
+
${allWorkItemRows.join("")}
|
|
19752
|
+
</tbody>
|
|
19753
|
+
</table>
|
|
19754
|
+
</div>`,
|
|
19755
|
+
{ titleTag: "h3", defaultCollapsed }
|
|
19756
|
+
);
|
|
19757
|
+
}
|
|
19758
|
+
var DONE_STATUSES6 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled", "decided"]);
|
|
19759
|
+
function computeOwnerCompletionPct(items, owner) {
|
|
19760
|
+
let total = 0;
|
|
19761
|
+
let progressSum = 0;
|
|
19762
|
+
function walk(list) {
|
|
19763
|
+
for (const w of list) {
|
|
19764
|
+
if (w.type !== "contribution" && w.owner === owner) {
|
|
19765
|
+
total++;
|
|
19766
|
+
progressSum += w.progress ?? (DONE_STATUSES6.has(w.status) ? 100 : 0);
|
|
19767
|
+
}
|
|
19768
|
+
if (w.children) walk(w.children);
|
|
19769
|
+
}
|
|
19770
|
+
}
|
|
19771
|
+
walk(items);
|
|
19772
|
+
return total > 0 ? Math.round(progressSum / total) : 0;
|
|
19773
|
+
}
|
|
19774
|
+
function filterItemsByOwner(items, owner) {
|
|
19775
|
+
const result = [];
|
|
19776
|
+
for (const item of items) {
|
|
19777
|
+
if (item.owner === owner) {
|
|
19778
|
+
result.push(item);
|
|
19779
|
+
} else if (item.children) {
|
|
19780
|
+
result.push(...filterItemsByOwner(item.children, owner));
|
|
19781
|
+
}
|
|
19782
|
+
}
|
|
19783
|
+
return result;
|
|
19784
|
+
}
|
|
19785
|
+
|
|
19431
19786
|
// src/web/templates/pages/po/delivery.ts
|
|
19787
|
+
var DONE_STATUSES7 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
19788
|
+
var priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
19789
|
+
var statusOrder = { "in-progress": 0, open: 1, draft: 2, blocked: 3, done: 4, closed: 5, resolved: 6 };
|
|
19790
|
+
function priorityClass2(p) {
|
|
19791
|
+
if (!p) return "";
|
|
19792
|
+
const lower = p.toLowerCase();
|
|
19793
|
+
if (lower === "critical" || lower === "high") return " priority-high";
|
|
19794
|
+
if (lower === "medium") return " priority-medium";
|
|
19795
|
+
if (lower === "low") return " priority-low";
|
|
19796
|
+
return "";
|
|
19797
|
+
}
|
|
19798
|
+
function miniProgressBar2(pct) {
|
|
19799
|
+
return `<div class="mini-progress-bar"><div class="mini-progress-fill" style="width:${pct}%"></div><span class="mini-progress-label">${pct}%</span></div>`;
|
|
19800
|
+
}
|
|
19432
19801
|
var PO_CONTRIBUTION_TYPES = /* @__PURE__ */ new Set([
|
|
19433
19802
|
"stakeholder-feedback",
|
|
19434
19803
|
"acceptance-result",
|
|
@@ -19454,24 +19823,8 @@ function poDeliveryPage(ctx) {
|
|
|
19454
19823
|
<p>No active sprint found. Create a sprint and set its status to "active" to track delivery.</p>
|
|
19455
19824
|
</div>`;
|
|
19456
19825
|
}
|
|
19457
|
-
const
|
|
19458
|
-
|
|
19459
|
-
);
|
|
19460
|
-
function findContributions(items, parentId) {
|
|
19461
|
-
const result = [];
|
|
19462
|
-
for (const item of items) {
|
|
19463
|
-
if (item.type === "contribution" && PO_CONTRIBUTION_TYPES.has(item.id.split("-").slice(0, -1).join("-") || "")) {
|
|
19464
|
-
result.push({ id: item.id, title: item.title, type: item.type, status: item.status, parentId });
|
|
19465
|
-
}
|
|
19466
|
-
if (PO_CONTRIBUTION_TYPES.has(item.type)) {
|
|
19467
|
-
result.push({ id: item.id, title: item.title, type: item.type, status: item.status, parentId });
|
|
19468
|
-
}
|
|
19469
|
-
if (item.children) {
|
|
19470
|
-
result.push(...findContributions(item.children, item.id));
|
|
19471
|
-
}
|
|
19472
|
-
}
|
|
19473
|
-
return result;
|
|
19474
|
-
}
|
|
19826
|
+
const poItems = filterItemsByOwner(data.workItems.items, "po");
|
|
19827
|
+
const poCompletionPct = computeOwnerCompletionPct(data.workItems.items, "po");
|
|
19475
19828
|
const allDocs = ctx.store.list();
|
|
19476
19829
|
const poContributions = allDocs.filter((d) => PO_CONTRIBUTION_TYPES.has(d.frontmatter.type));
|
|
19477
19830
|
const statsCards = `
|
|
@@ -19481,16 +19834,16 @@ function poDeliveryPage(ctx) {
|
|
|
19481
19834
|
<div class="card-value">${data.workItems.completionPct}%</div>
|
|
19482
19835
|
<div class="card-sub">${data.workItems.done} / ${data.workItems.total} items done</div>
|
|
19483
19836
|
</div>
|
|
19837
|
+
<div class="card">
|
|
19838
|
+
<div class="card-label">PO Completion</div>
|
|
19839
|
+
<div class="card-value">${poCompletionPct}%</div>
|
|
19840
|
+
<div class="card-sub">${poItems.length} owned items</div>
|
|
19841
|
+
</div>
|
|
19484
19842
|
<div class="card">
|
|
19485
19843
|
<div class="card-label">Days Remaining</div>
|
|
19486
19844
|
<div class="card-value">${data.timeline.daysRemaining}</div>
|
|
19487
19845
|
<div class="card-sub">${data.timeline.daysElapsed} of ${data.timeline.totalDays} elapsed</div>
|
|
19488
19846
|
</div>
|
|
19489
|
-
<div class="card">
|
|
19490
|
-
<div class="card-label">Features Done</div>
|
|
19491
|
-
<div class="card-value">${doneFeatures.length}</div>
|
|
19492
|
-
<div class="card-sub">this sprint</div>
|
|
19493
|
-
</div>
|
|
19494
19847
|
<div class="card">
|
|
19495
19848
|
<div class="card-label">PO Contributions</div>
|
|
19496
19849
|
<div class="card-value">${poContributions.length}</div>
|
|
@@ -19502,7 +19855,11 @@ function poDeliveryPage(ctx) {
|
|
|
19502
19855
|
<strong>${escapeHtml(data.sprint.id)} \u2014 ${escapeHtml(data.sprint.title)}</strong>
|
|
19503
19856
|
${data.sprint.goal ? ` | ${escapeHtml(data.sprint.goal)}` : ""}
|
|
19504
19857
|
</div>`;
|
|
19505
|
-
const
|
|
19858
|
+
const workItemsSection = renderWorkItemsTable(poItems, {
|
|
19859
|
+
sectionId: "po-delivery-items",
|
|
19860
|
+
title: "PO Work Items"
|
|
19861
|
+
});
|
|
19862
|
+
const epicsSection = data.linkedEpics.length > 0 ? collapsibleSection(
|
|
19506
19863
|
"po-delivery-epics",
|
|
19507
19864
|
"Linked Epics",
|
|
19508
19865
|
`<div class="table-wrap">
|
|
@@ -19523,6 +19880,82 @@ function poDeliveryPage(ctx) {
|
|
|
19523
19880
|
</div>`,
|
|
19524
19881
|
{ titleTag: "h3" }
|
|
19525
19882
|
) : "";
|
|
19883
|
+
const features = ctx.store.list({ type: "feature" });
|
|
19884
|
+
const epics = ctx.store.list({ type: "epic" });
|
|
19885
|
+
const allTasks = ctx.store.list({ type: "task" });
|
|
19886
|
+
const sprints = ctx.store.list({ type: "sprint" });
|
|
19887
|
+
const featureToEpics = /* @__PURE__ */ new Map();
|
|
19888
|
+
for (const epic of epics) {
|
|
19889
|
+
const featureIds = normalizeLinkedFeatures(epic.frontmatter.linkedFeature);
|
|
19890
|
+
for (const fid of featureIds) {
|
|
19891
|
+
const arr = featureToEpics.get(fid) ?? [];
|
|
19892
|
+
arr.push(epic);
|
|
19893
|
+
featureToEpics.set(fid, arr);
|
|
19894
|
+
}
|
|
19895
|
+
}
|
|
19896
|
+
const epicToTasks = /* @__PURE__ */ new Map();
|
|
19897
|
+
for (const task of allTasks) {
|
|
19898
|
+
const tags = task.frontmatter.tags ?? [];
|
|
19899
|
+
for (const tag of tags) {
|
|
19900
|
+
if (tag.startsWith("epic:")) {
|
|
19901
|
+
const arr = epicToTasks.get(tag.slice(5)) ?? [];
|
|
19902
|
+
arr.push(task);
|
|
19903
|
+
epicToTasks.set(tag.slice(5), arr);
|
|
19904
|
+
}
|
|
19905
|
+
}
|
|
19906
|
+
}
|
|
19907
|
+
const activeSprint = sprints.find((s) => s.frontmatter.status === "active");
|
|
19908
|
+
const activeSprintEpicIds = new Set(
|
|
19909
|
+
activeSprint ? normalizeLinkedEpics(activeSprint.frontmatter.linkedEpics) : []
|
|
19910
|
+
);
|
|
19911
|
+
function featureSprintLabel(featureId) {
|
|
19912
|
+
if (!activeSprint) return "\u2014";
|
|
19913
|
+
const fEpics = featureToEpics.get(featureId) ?? [];
|
|
19914
|
+
return fEpics.some((e) => activeSprintEpicIds.has(e.frontmatter.id)) ? escapeHtml(activeSprint.frontmatter.id) : "\u2014";
|
|
19915
|
+
}
|
|
19916
|
+
function featureProgress(featureId) {
|
|
19917
|
+
const fEpics = featureToEpics.get(featureId) ?? [];
|
|
19918
|
+
let total = 0;
|
|
19919
|
+
let progressSum = 0;
|
|
19920
|
+
for (const epic of fEpics) {
|
|
19921
|
+
for (const t of epicToTasks.get(epic.frontmatter.id) ?? []) {
|
|
19922
|
+
total++;
|
|
19923
|
+
progressSum += getEffectiveProgress(t.frontmatter);
|
|
19924
|
+
}
|
|
19925
|
+
}
|
|
19926
|
+
return total > 0 ? Math.round(progressSum / total) : 0;
|
|
19927
|
+
}
|
|
19928
|
+
const nonDoneFeatures = features.filter((f) => !DONE_STATUSES7.has(f.frontmatter.status)).sort((a, b) => {
|
|
19929
|
+
const pa = priorityOrder[a.frontmatter.priority?.toLowerCase()] ?? 99;
|
|
19930
|
+
const pb = priorityOrder[b.frontmatter.priority?.toLowerCase()] ?? 99;
|
|
19931
|
+
if (pa !== pb) return pa - pb;
|
|
19932
|
+
const sa = statusOrder[a.frontmatter.status] ?? 3;
|
|
19933
|
+
const sb = statusOrder[b.frontmatter.status] ?? 3;
|
|
19934
|
+
return sa - sb;
|
|
19935
|
+
});
|
|
19936
|
+
const priorityQueueSection = collapsibleSection(
|
|
19937
|
+
"po-priority-queue",
|
|
19938
|
+
`Priority Queue (${nonDoneFeatures.length})`,
|
|
19939
|
+
nonDoneFeatures.length > 0 ? `<div class="table-wrap">
|
|
19940
|
+
<table>
|
|
19941
|
+
<thead>
|
|
19942
|
+
<tr><th>Priority</th><th>ID</th><th>Title</th><th>Status</th><th>Sprint</th><th>Progress</th></tr>
|
|
19943
|
+
</thead>
|
|
19944
|
+
<tbody>
|
|
19945
|
+
${nonDoneFeatures.map((f) => `
|
|
19946
|
+
<tr>
|
|
19947
|
+
<td><span class="${priorityClass2(f.frontmatter.priority)}">${escapeHtml(f.frontmatter.priority ?? "\u2014")}</span></td>
|
|
19948
|
+
<td><a href="/docs/feature/${escapeHtml(f.frontmatter.id)}">${escapeHtml(f.frontmatter.id)}</a></td>
|
|
19949
|
+
<td>${escapeHtml(f.frontmatter.title)}</td>
|
|
19950
|
+
<td>${statusBadge(f.frontmatter.status)}</td>
|
|
19951
|
+
<td>${featureSprintLabel(f.frontmatter.id)}</td>
|
|
19952
|
+
<td>${miniProgressBar2(featureProgress(f.frontmatter.id))}</td>
|
|
19953
|
+
</tr>`).join("")}
|
|
19954
|
+
</tbody>
|
|
19955
|
+
</table>
|
|
19956
|
+
</div>` : '<div class="empty"><p>No active features in the queue.</p></div>',
|
|
19957
|
+
{ titleTag: "h3" }
|
|
19958
|
+
);
|
|
19526
19959
|
const contributionsSection = poContributions.length > 0 ? collapsibleSection(
|
|
19527
19960
|
"po-delivery-contributions",
|
|
19528
19961
|
`PO Contributions (${poContributions.length})`,
|
|
@@ -19553,7 +19986,9 @@ function poDeliveryPage(ctx) {
|
|
|
19553
19986
|
${sprintHeader}
|
|
19554
19987
|
${progressBar(data.workItems.completionPct)}
|
|
19555
19988
|
${statsCards}
|
|
19556
|
-
${
|
|
19989
|
+
${workItemsSection}
|
|
19990
|
+
${epicsSection}
|
|
19991
|
+
${priorityQueueSection}
|
|
19557
19992
|
${contributionsSection}
|
|
19558
19993
|
`;
|
|
19559
19994
|
}
|
|
@@ -19579,6 +20014,12 @@ function renderGarWidget(report) {
|
|
|
19579
20014
|
}
|
|
19580
20015
|
|
|
19581
20016
|
// src/web/templates/pages/po/stakeholders.ts
|
|
20017
|
+
var KNOWN_OWNERS3 = /* @__PURE__ */ new Set(["po", "tl", "dm"]);
|
|
20018
|
+
function ownerBadge3(owner) {
|
|
20019
|
+
if (!owner) return '<span class="text-dim">\u2014</span>';
|
|
20020
|
+
const cls = KNOWN_OWNERS3.has(owner.toLowerCase()) ? `owner-badge-${owner.toLowerCase()}` : "owner-badge-other";
|
|
20021
|
+
return `<span class="owner-badge ${cls}">${escapeHtml(owner.toUpperCase())}</span>`;
|
|
20022
|
+
}
|
|
19582
20023
|
function poStakeholdersPage(ctx) {
|
|
19583
20024
|
const garReport = getGarData(ctx.store, ctx.projectName);
|
|
19584
20025
|
const actions = ctx.store.list({ type: "action" });
|
|
@@ -19629,7 +20070,7 @@ function poStakeholdersPage(ctx) {
|
|
|
19629
20070
|
<td><a href="/docs/action/${escapeHtml(d.frontmatter.id)}">${escapeHtml(d.frontmatter.id)}</a></td>
|
|
19630
20071
|
<td>${escapeHtml(d.frontmatter.title)}</td>
|
|
19631
20072
|
<td>${statusBadge(d.frontmatter.status)}</td>
|
|
19632
|
-
<td>${
|
|
20073
|
+
<td>${ownerBadge3(d.frontmatter.owner)}</td>
|
|
19633
20074
|
<td>${d.frontmatter.dueDate ? formatDate(d.frontmatter.dueDate) : '<span class="text-dim">\u2014</span>'}</td>
|
|
19634
20075
|
</tr>`).join("")}
|
|
19635
20076
|
</tbody>
|
|
@@ -19652,7 +20093,7 @@ function poStakeholdersPage(ctx) {
|
|
|
19652
20093
|
<tr>
|
|
19653
20094
|
<td><a href="/docs/question/${escapeHtml(d.frontmatter.id)}">${escapeHtml(d.frontmatter.id)}</a></td>
|
|
19654
20095
|
<td>${escapeHtml(d.frontmatter.title)}</td>
|
|
19655
|
-
<td>${
|
|
20096
|
+
<td>${ownerBadge3(d.frontmatter.owner)}</td>
|
|
19656
20097
|
<td>${formatDate(d.frontmatter.created)}</td>
|
|
19657
20098
|
</tr>`).join("")}
|
|
19658
20099
|
</tbody>
|
|
@@ -19693,7 +20134,7 @@ registerPersonaPage("po", "delivery", poDeliveryPage);
|
|
|
19693
20134
|
registerPersonaPage("po", "stakeholders", poStakeholdersPage);
|
|
19694
20135
|
|
|
19695
20136
|
// src/web/templates/pages/dm/dashboard.ts
|
|
19696
|
-
var
|
|
20137
|
+
var DONE_STATUSES8 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
19697
20138
|
function progressBar2(pct) {
|
|
19698
20139
|
return `<div class="sprint-progress-bar">
|
|
19699
20140
|
<div class="sprint-progress-fill" style="width: ${pct}%"></div>
|
|
@@ -19704,7 +20145,7 @@ function dmDashboardPage(ctx) {
|
|
|
19704
20145
|
const sprintData = getSprintSummaryData(ctx.store);
|
|
19705
20146
|
const upcoming = getUpcomingData(ctx.store);
|
|
19706
20147
|
const actions = ctx.store.list({ type: "action" });
|
|
19707
|
-
const openActions = actions.filter((d) => !
|
|
20148
|
+
const openActions = actions.filter((d) => !DONE_STATUSES8.has(d.frontmatter.status));
|
|
19708
20149
|
const overdueActions = upcoming.dueSoonActions.filter((a) => a.urgency === "overdue");
|
|
19709
20150
|
const statsCards = `
|
|
19710
20151
|
<div class="cards">
|
|
@@ -19791,42 +20232,46 @@ function dmDashboardPage(ctx) {
|
|
|
19791
20232
|
`;
|
|
19792
20233
|
}
|
|
19793
20234
|
|
|
19794
|
-
// src/web/templates/pages/sprint
|
|
20235
|
+
// src/web/templates/pages/dm/sprint.ts
|
|
19795
20236
|
function progressBar3(pct) {
|
|
19796
20237
|
return `<div class="sprint-progress-bar">
|
|
19797
20238
|
<div class="sprint-progress-fill" style="width: ${pct}%"></div>
|
|
19798
20239
|
<span class="sprint-progress-label">${pct}%</span>
|
|
19799
20240
|
</div>`;
|
|
19800
20241
|
}
|
|
19801
|
-
function
|
|
20242
|
+
function dmSprintPage(ctx) {
|
|
20243
|
+
const data = getSprintSummaryData(ctx.store);
|
|
19802
20244
|
if (!data) {
|
|
19803
20245
|
return `
|
|
19804
20246
|
<div class="page-header">
|
|
19805
|
-
<h2>Sprint
|
|
19806
|
-
<div class="subtitle">
|
|
20247
|
+
<h2>Sprint Execution</h2>
|
|
20248
|
+
<div class="subtitle">Full sprint oversight and delivery tracking</div>
|
|
19807
20249
|
</div>
|
|
19808
20250
|
<div class="empty">
|
|
19809
20251
|
<h3>No Active Sprint</h3>
|
|
19810
|
-
<p>No active sprint found. Create a sprint and set its status to "active" to
|
|
20252
|
+
<p>No active sprint found. Create a sprint and set its status to "active" to track execution.</p>
|
|
19811
20253
|
</div>`;
|
|
19812
20254
|
}
|
|
20255
|
+
const dmCompletionPct = computeOwnerCompletionPct(data.workItems.items, "dm");
|
|
20256
|
+
const goalHtml = data.sprint.goal ? `<div class="sprint-goal"><strong>Goal:</strong> ${escapeHtml(data.sprint.goal)}</div>` : "";
|
|
20257
|
+
const dateRange = data.sprint.startDate && data.sprint.endDate ? `<span class="text-dim">${formatDate(data.sprint.startDate)} \u2014 ${formatDate(data.sprint.endDate)}</span>` : "";
|
|
19813
20258
|
const statsCards = `
|
|
19814
20259
|
<div class="cards">
|
|
19815
20260
|
<div class="card">
|
|
19816
|
-
<div class="card-label">Completion</div>
|
|
20261
|
+
<div class="card-label">Sprint Completion</div>
|
|
19817
20262
|
<div class="card-value">${data.workItems.completionPct}%</div>
|
|
19818
20263
|
<div class="card-sub">${data.workItems.done} / ${data.workItems.total} items done</div>
|
|
19819
20264
|
</div>
|
|
20265
|
+
<div class="card">
|
|
20266
|
+
<div class="card-label">DM Completion</div>
|
|
20267
|
+
<div class="card-value">${dmCompletionPct}%</div>
|
|
20268
|
+
<div class="card-sub">DM-owned items</div>
|
|
20269
|
+
</div>
|
|
19820
20270
|
<div class="card">
|
|
19821
20271
|
<div class="card-label">Days Remaining</div>
|
|
19822
20272
|
<div class="card-value">${data.timeline.daysRemaining}</div>
|
|
19823
20273
|
<div class="card-sub">${data.timeline.daysElapsed} of ${data.timeline.totalDays} elapsed</div>
|
|
19824
20274
|
</div>
|
|
19825
|
-
<div class="card">
|
|
19826
|
-
<div class="card-label">Epics</div>
|
|
19827
|
-
<div class="card-value">${data.linkedEpics.length}</div>
|
|
19828
|
-
<div class="card-sub">linked to sprint</div>
|
|
19829
|
-
</div>
|
|
19830
20275
|
<a class="card card-link" href="sprint-blockers">
|
|
19831
20276
|
<div class="card-label">Blockers</div>
|
|
19832
20277
|
<div class="card-value${data.blockers.length > 0 ? " priority-high" : ""}">${data.blockers.length}</div>
|
|
@@ -19838,13 +20283,18 @@ function sprintSummaryPage(data, cached2) {
|
|
|
19838
20283
|
<div class="card-sub">open risk items</div>
|
|
19839
20284
|
</a>
|
|
19840
20285
|
</div>`;
|
|
19841
|
-
const
|
|
19842
|
-
"
|
|
20286
|
+
const workItemsSection = renderWorkItemsTable(data.workItems.items, {
|
|
20287
|
+
sectionId: "dm-sprint-items",
|
|
20288
|
+
title: "Sprint Work Items",
|
|
20289
|
+
showOwner: true
|
|
20290
|
+
});
|
|
20291
|
+
const epicsSection = data.linkedEpics.length > 0 ? collapsibleSection(
|
|
20292
|
+
"dm-sprint-epics",
|
|
19843
20293
|
"Linked Epics",
|
|
19844
20294
|
`<div class="table-wrap">
|
|
19845
20295
|
<table>
|
|
19846
20296
|
<thead>
|
|
19847
|
-
<tr><th>ID</th><th>Title</th><th>Status</th><th>Tasks</th></tr>
|
|
20297
|
+
<tr><th>ID</th><th>Title</th><th>Status</th><th>Tasks Done</th></tr>
|
|
19848
20298
|
</thead>
|
|
19849
20299
|
<tbody>
|
|
19850
20300
|
${data.linkedEpics.map((e) => `
|
|
@@ -19859,138 +20309,29 @@ function sprintSummaryPage(data, cached2) {
|
|
|
19859
20309
|
</div>`,
|
|
19860
20310
|
{ titleTag: "h3" }
|
|
19861
20311
|
) : "";
|
|
19862
|
-
const
|
|
19863
|
-
"
|
|
19864
|
-
|
|
19865
|
-
"hsl(280, 45%, 55%)",
|
|
19866
|
-
"hsl(30, 65%, 55%)",
|
|
19867
|
-
"hsl(340, 50%, 55%)",
|
|
19868
|
-
"hsl(190, 50%, 45%)",
|
|
19869
|
-
"hsl(60, 50%, 50%)",
|
|
19870
|
-
"hsl(120, 40%, 45%)"
|
|
19871
|
-
];
|
|
19872
|
-
function hashString(s) {
|
|
19873
|
-
let h = 0;
|
|
19874
|
-
for (let i = 0; i < s.length; i++) {
|
|
19875
|
-
h = (h << 5) - h + s.charCodeAt(i) | 0;
|
|
19876
|
-
}
|
|
19877
|
-
return Math.abs(h);
|
|
19878
|
-
}
|
|
19879
|
-
const focusGroups = /* @__PURE__ */ new Map();
|
|
19880
|
-
for (const item of data.workItems.items) {
|
|
19881
|
-
const focus = item.workFocus ?? "Unassigned";
|
|
19882
|
-
if (!focusGroups.has(focus)) focusGroups.set(focus, []);
|
|
19883
|
-
focusGroups.get(focus).push(item);
|
|
19884
|
-
}
|
|
19885
|
-
const focusColorMap = /* @__PURE__ */ new Map();
|
|
19886
|
-
for (const name of focusGroups.keys()) {
|
|
19887
|
-
focusColorMap.set(name, FOCUS_BORDER_PALETTE[hashString(name) % FOCUS_BORDER_PALETTE.length]);
|
|
19888
|
-
}
|
|
19889
|
-
function countFocusStats(items) {
|
|
19890
|
-
let total = 0;
|
|
19891
|
-
let done = 0;
|
|
19892
|
-
let inProgress = 0;
|
|
19893
|
-
function walk(list) {
|
|
19894
|
-
for (const w of list) {
|
|
19895
|
-
if (w.type !== "contribution") {
|
|
19896
|
-
total++;
|
|
19897
|
-
const s = w.status.toLowerCase();
|
|
19898
|
-
if (s === "done" || s === "closed" || s === "resolved" || s === "decided") done++;
|
|
19899
|
-
else if (s === "in-progress" || s === "in progress") inProgress++;
|
|
19900
|
-
}
|
|
19901
|
-
if (w.children) walk(w.children);
|
|
19902
|
-
}
|
|
19903
|
-
}
|
|
19904
|
-
walk(items);
|
|
19905
|
-
return { total, done, inProgress };
|
|
19906
|
-
}
|
|
19907
|
-
function renderItemRows(items, borderColor, depth = 0) {
|
|
19908
|
-
return items.flatMap((w) => {
|
|
19909
|
-
const isChild = depth > 0;
|
|
19910
|
-
const isContribution = w.type === "contribution";
|
|
19911
|
-
const classes = ["focus-row"];
|
|
19912
|
-
if (isContribution) classes.push("contribution-row");
|
|
19913
|
-
else if (isChild) classes.push("child-row");
|
|
19914
|
-
const indent = depth > 0 ? ` style="padding-left: ${0.75 + depth * 1}rem"` : "";
|
|
19915
|
-
const progressCell = !isContribution && w.progress !== void 0 ? `<div class="mini-progress-bar"><div class="mini-progress-fill" style="width:${w.progress}%"></div><span class="mini-progress-label">${w.progress}%</span></div>` : "";
|
|
19916
|
-
const row = `
|
|
19917
|
-
<tr class="${classes.join(" ")}" style="--focus-color: ${borderColor}">
|
|
19918
|
-
<td${indent}><a href="/docs/${escapeHtml(w.type)}/${escapeHtml(w.id)}">${escapeHtml(w.id)}</a></td>
|
|
19919
|
-
<td>${escapeHtml(w.title)}</td>
|
|
19920
|
-
<td>${statusBadge(w.status)}</td>
|
|
19921
|
-
<td>${progressCell}</td>
|
|
19922
|
-
</tr>`;
|
|
19923
|
-
const childRows = w.children ? renderItemRows(w.children, borderColor, depth + 1) : [];
|
|
19924
|
-
return [row, ...childRows];
|
|
19925
|
-
});
|
|
19926
|
-
}
|
|
19927
|
-
const allWorkItemRows = [];
|
|
19928
|
-
for (const [focus, items] of focusGroups) {
|
|
19929
|
-
const color = focusColorMap.get(focus);
|
|
19930
|
-
const stats = countFocusStats(items);
|
|
19931
|
-
const pct = stats.total > 0 ? Math.round(stats.done / stats.total * 100) : 0;
|
|
19932
|
-
const summaryParts = [];
|
|
19933
|
-
if (stats.done > 0) summaryParts.push(`${stats.done} done`);
|
|
19934
|
-
if (stats.inProgress > 0) summaryParts.push(`${stats.inProgress} in progress`);
|
|
19935
|
-
const remaining = stats.total - stats.done - stats.inProgress;
|
|
19936
|
-
if (remaining > 0) summaryParts.push(`${remaining} open`);
|
|
19937
|
-
allWorkItemRows.push(`
|
|
19938
|
-
<tr class="focus-group-header" style="--focus-color: ${color}">
|
|
19939
|
-
<td colspan="2">
|
|
19940
|
-
<span class="focus-group-name">${escapeHtml(focus)}</span>
|
|
19941
|
-
<span class="focus-group-stats">${summaryParts.join(" / ")}</span>
|
|
19942
|
-
</td>
|
|
19943
|
-
<td colspan="2">
|
|
19944
|
-
<div class="mini-progress-bar focus-group-progress"><div class="mini-progress-fill" style="width:${pct}%"></div><span class="mini-progress-label">${pct}%</span></div>
|
|
19945
|
-
</td>
|
|
19946
|
-
</tr>`);
|
|
19947
|
-
allWorkItemRows.push(...renderItemRows(items, color));
|
|
19948
|
-
}
|
|
19949
|
-
const tableHeaders = `<tr>
|
|
19950
|
-
<th>ID</th>
|
|
19951
|
-
<th>Title</th>
|
|
19952
|
-
<th>Status</th>
|
|
19953
|
-
<th>Progress</th>
|
|
19954
|
-
</tr>`;
|
|
19955
|
-
const workItemsSection = allWorkItemRows.length > 0 ? collapsibleSection(
|
|
19956
|
-
"ss-work-items",
|
|
19957
|
-
"Work Items",
|
|
19958
|
-
`<div class="table-wrap">
|
|
19959
|
-
<table id="work-items-table">
|
|
19960
|
-
<thead>
|
|
19961
|
-
${tableHeaders}
|
|
19962
|
-
</thead>
|
|
19963
|
-
<tbody>
|
|
19964
|
-
${allWorkItemRows.join("")}
|
|
19965
|
-
</tbody>
|
|
19966
|
-
</table>
|
|
19967
|
-
</div>`,
|
|
19968
|
-
{ titleTag: "h3", defaultCollapsed: true }
|
|
19969
|
-
) : "";
|
|
19970
|
-
const activitySection = data.artifacts.length > 0 ? collapsibleSection(
|
|
19971
|
-
"ss-activity",
|
|
19972
|
-
"Recent Activity",
|
|
20312
|
+
const actionsSection = data.openActions.length > 0 ? collapsibleSection(
|
|
20313
|
+
"dm-sprint-actions",
|
|
20314
|
+
`Open Actions (${data.openActions.length})`,
|
|
19973
20315
|
`<div class="table-wrap">
|
|
19974
20316
|
<table>
|
|
19975
20317
|
<thead>
|
|
19976
|
-
<tr><th>
|
|
20318
|
+
<tr><th>ID</th><th>Title</th><th>Owner</th><th>Due Date</th></tr>
|
|
19977
20319
|
</thead>
|
|
19978
20320
|
<tbody>
|
|
19979
|
-
${data.
|
|
20321
|
+
${data.openActions.map((a) => `
|
|
19980
20322
|
<tr>
|
|
19981
|
-
<td>${
|
|
19982
|
-
<td><a href="/docs/${escapeHtml(a.type)}/${escapeHtml(a.id)}">${escapeHtml(a.id)}</a></td>
|
|
20323
|
+
<td><a href="/docs/action/${escapeHtml(a.id)}">${escapeHtml(a.id)}</a></td>
|
|
19983
20324
|
<td>${escapeHtml(a.title)}</td>
|
|
19984
|
-
<td>${escapeHtml(
|
|
19985
|
-
<td>${
|
|
20325
|
+
<td>${a.owner ? escapeHtml(a.owner) : '<span class="text-dim">\u2014</span>'}</td>
|
|
20326
|
+
<td>${a.dueDate ? formatDate(a.dueDate) : '<span class="text-dim">\u2014</span>'}</td>
|
|
19986
20327
|
</tr>`).join("")}
|
|
19987
20328
|
</tbody>
|
|
19988
20329
|
</table>
|
|
19989
20330
|
</div>`,
|
|
19990
|
-
{ titleTag: "h3"
|
|
20331
|
+
{ titleTag: "h3" }
|
|
19991
20332
|
) : "";
|
|
19992
20333
|
const meetingsSection = data.meetings.length > 0 ? collapsibleSection(
|
|
19993
|
-
"
|
|
20334
|
+
"dm-sprint-meetings",
|
|
19994
20335
|
`Meetings (${data.meetings.length})`,
|
|
19995
20336
|
`<div class="table-wrap">
|
|
19996
20337
|
<table>
|
|
@@ -20009,79 +20350,23 @@ function sprintSummaryPage(data, cached2) {
|
|
|
20009
20350
|
</div>`,
|
|
20010
20351
|
{ titleTag: "h3", defaultCollapsed: true }
|
|
20011
20352
|
) : "";
|
|
20012
|
-
const goalHtml = data.sprint.goal ? `<div class="sprint-goal"><strong>Goal:</strong> ${escapeHtml(data.sprint.goal)}</div>` : "";
|
|
20013
|
-
const dateRange = data.sprint.startDate && data.sprint.endDate ? `<span class="text-dim">${formatDate(data.sprint.startDate)} \u2014 ${formatDate(data.sprint.endDate)}</span>` : "";
|
|
20014
20353
|
return `
|
|
20015
20354
|
<div class="page-header">
|
|
20016
20355
|
<h2>${escapeHtml(data.sprint.id)} \u2014 ${escapeHtml(data.sprint.title)} ${statusBadge(data.sprint.status)}</h2>
|
|
20017
|
-
<div class="subtitle">Sprint
|
|
20356
|
+
<div class="subtitle">Sprint Execution ${dateRange}</div>
|
|
20018
20357
|
</div>
|
|
20019
20358
|
${goalHtml}
|
|
20020
20359
|
${progressBar3(data.timeline.percentComplete)}
|
|
20021
20360
|
${statsCards}
|
|
20022
|
-
${epicsTable}
|
|
20023
20361
|
${workItemsSection}
|
|
20024
|
-
${
|
|
20362
|
+
${epicsSection}
|
|
20363
|
+
${actionsSection}
|
|
20025
20364
|
${meetingsSection}
|
|
20026
|
-
|
|
20027
|
-
<div class="sprint-ai-section">
|
|
20028
|
-
<h3>AI Summary</h3>
|
|
20029
|
-
${cached2 ? `<p class="text-dim">Generated ${formatDate(cached2.generatedAt)} at ${cached2.generatedAt.slice(11, 16)} UTC</p>` : `<p class="text-dim">Generate a narrative summary of this sprint's progress, risks, and projections.</p>`}
|
|
20030
|
-
<button class="sprint-generate-btn" onclick="generateSummary()" id="generate-btn">${cached2 ? "Regenerate" : "Generate AI Summary"}</button>
|
|
20031
|
-
<div id="summary-loading" class="sprint-loading" style="display:none">
|
|
20032
|
-
<div class="sprint-spinner"></div>
|
|
20033
|
-
<span>Generating summary...</span>
|
|
20034
|
-
</div>
|
|
20035
|
-
<div id="summary-error" class="sprint-error" style="display:none"></div>
|
|
20036
|
-
<div id="summary-content" class="detail-content"${cached2 ? "" : ' style="display:none"'}>${cached2 ? cached2.html : ""}</div>
|
|
20037
|
-
</div>
|
|
20038
|
-
|
|
20039
|
-
<script>
|
|
20040
|
-
async function generateSummary() {
|
|
20041
|
-
var btn = document.getElementById('generate-btn');
|
|
20042
|
-
var loading = document.getElementById('summary-loading');
|
|
20043
|
-
var errorEl = document.getElementById('summary-error');
|
|
20044
|
-
var content = document.getElementById('summary-content');
|
|
20045
|
-
|
|
20046
|
-
btn.disabled = true;
|
|
20047
|
-
btn.style.display = 'none';
|
|
20048
|
-
loading.style.display = 'flex';
|
|
20049
|
-
errorEl.style.display = 'none';
|
|
20050
|
-
content.style.display = 'none';
|
|
20051
|
-
|
|
20052
|
-
try {
|
|
20053
|
-
var res = await fetch('/api/sprint-summary', {
|
|
20054
|
-
method: 'POST',
|
|
20055
|
-
headers: { 'Content-Type': 'application/json' },
|
|
20056
|
-
body: JSON.stringify({ sprintId: '${escapeHtml(data.sprint.id)}' })
|
|
20057
|
-
});
|
|
20058
|
-
var json = await res.json();
|
|
20059
|
-
if (!res.ok) throw new Error(json.error || 'Failed to generate summary');
|
|
20060
|
-
loading.style.display = 'none';
|
|
20061
|
-
content.innerHTML = json.html;
|
|
20062
|
-
content.style.display = 'block';
|
|
20063
|
-
btn.textContent = 'Regenerate';
|
|
20064
|
-
btn.style.display = '';
|
|
20065
|
-
btn.disabled = false;
|
|
20066
|
-
} catch (e) {
|
|
20067
|
-
loading.style.display = 'none';
|
|
20068
|
-
errorEl.textContent = e.message;
|
|
20069
|
-
errorEl.style.display = 'block';
|
|
20070
|
-
btn.style.display = '';
|
|
20071
|
-
btn.disabled = false;
|
|
20072
|
-
}
|
|
20073
|
-
}
|
|
20074
|
-
</script>`;
|
|
20075
|
-
}
|
|
20076
|
-
|
|
20077
|
-
// src/web/templates/pages/dm/sprint.ts
|
|
20078
|
-
function dmSprintPage(ctx) {
|
|
20079
|
-
const data = getSprintSummaryData(ctx.store);
|
|
20080
|
-
return sprintSummaryPage(data);
|
|
20365
|
+
`;
|
|
20081
20366
|
}
|
|
20082
20367
|
|
|
20083
20368
|
// src/web/templates/pages/dm/actions.ts
|
|
20084
|
-
var
|
|
20369
|
+
var DONE_STATUSES9 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
20085
20370
|
function urgencyBadge(tier) {
|
|
20086
20371
|
const labels = {
|
|
20087
20372
|
overdue: "Overdue",
|
|
@@ -20101,7 +20386,7 @@ function urgencyRowClass(tier) {
|
|
|
20101
20386
|
function dmActionsPage(ctx) {
|
|
20102
20387
|
const upcoming = getUpcomingData(ctx.store);
|
|
20103
20388
|
const allActions = ctx.store.list({ type: "action" });
|
|
20104
|
-
const openActions = allActions.filter((d) => !
|
|
20389
|
+
const openActions = allActions.filter((d) => !DONE_STATUSES9.has(d.frontmatter.status));
|
|
20105
20390
|
const overdueActions = upcoming.dueSoonActions.filter((a) => a.urgency === "overdue");
|
|
20106
20391
|
const dueThisWeek = upcoming.dueSoonActions.filter((a) => a.urgency === "due-3d" || a.urgency === "due-7d");
|
|
20107
20392
|
const unownedActions = openActions.filter((d) => !d.frontmatter.owner);
|
|
@@ -20186,7 +20471,7 @@ function dmActionsPage(ctx) {
|
|
|
20186
20471
|
}
|
|
20187
20472
|
|
|
20188
20473
|
// src/web/templates/pages/dm/risks.ts
|
|
20189
|
-
var
|
|
20474
|
+
var DONE_STATUSES10 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
20190
20475
|
function dmRisksPage(ctx) {
|
|
20191
20476
|
const allDocs = ctx.store.list();
|
|
20192
20477
|
const upcoming = getUpcomingData(ctx.store);
|
|
@@ -20197,7 +20482,7 @@ function dmRisksPage(ctx) {
|
|
|
20197
20482
|
const todayMs = new Date(today).getTime();
|
|
20198
20483
|
const fourteenDaysMs = 14 * 864e5;
|
|
20199
20484
|
const agingItems = allDocs.filter((d) => {
|
|
20200
|
-
if (
|
|
20485
|
+
if (DONE_STATUSES10.has(d.frontmatter.status)) return false;
|
|
20201
20486
|
if (!["action", "question"].includes(d.frontmatter.type)) return false;
|
|
20202
20487
|
const createdMs = new Date(d.frontmatter.created).getTime();
|
|
20203
20488
|
return todayMs - createdMs > fourteenDaysMs;
|
|
@@ -20311,7 +20596,7 @@ function dmRisksPage(ctx) {
|
|
|
20311
20596
|
}
|
|
20312
20597
|
|
|
20313
20598
|
// src/web/templates/pages/dm/meetings.ts
|
|
20314
|
-
var
|
|
20599
|
+
var DONE_STATUSES11 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
20315
20600
|
function dmMeetingsPage(ctx) {
|
|
20316
20601
|
const meetings = ctx.store.list({ type: "meeting" });
|
|
20317
20602
|
const actions = ctx.store.list({ type: "action" });
|
|
@@ -20357,7 +20642,7 @@ function dmMeetingsPage(ctx) {
|
|
|
20357
20642
|
${sortedMeetings.map((m) => {
|
|
20358
20643
|
const date5 = m.frontmatter.date ?? m.frontmatter.created;
|
|
20359
20644
|
const relatedActions = meetingActionMap.get(m.frontmatter.id) ?? [];
|
|
20360
|
-
const openCount = relatedActions.filter((a) => !
|
|
20645
|
+
const openCount = relatedActions.filter((a) => !DONE_STATUSES11.has(a.frontmatter.status)).length;
|
|
20361
20646
|
return `
|
|
20362
20647
|
<tr>
|
|
20363
20648
|
<td>${formatDate(date5)}</td>
|
|
@@ -20372,7 +20657,7 @@ function dmMeetingsPage(ctx) {
|
|
|
20372
20657
|
const recentMeetingActions = [];
|
|
20373
20658
|
for (const [mid, acts] of meetingActionMap) {
|
|
20374
20659
|
for (const act of acts) {
|
|
20375
|
-
if (!
|
|
20660
|
+
if (!DONE_STATUSES11.has(act.frontmatter.status)) {
|
|
20376
20661
|
recentMeetingActions.push({ action: act, meetingId: mid });
|
|
20377
20662
|
}
|
|
20378
20663
|
}
|
|
@@ -20567,16 +20852,16 @@ registerPersonaPage("dm", "meetings", dmMeetingsPage);
|
|
|
20567
20852
|
registerPersonaPage("dm", "governance", dmGovernancePage);
|
|
20568
20853
|
|
|
20569
20854
|
// src/web/templates/pages/tl/dashboard.ts
|
|
20570
|
-
var
|
|
20571
|
-
var
|
|
20855
|
+
var DONE_STATUSES12 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
20856
|
+
var RESOLVED_DECISION_STATUSES2 = /* @__PURE__ */ new Set(["decided", "superseded", "dismissed"]);
|
|
20572
20857
|
function tlDashboardPage(ctx) {
|
|
20573
20858
|
const epics = ctx.store.list({ type: "epic" });
|
|
20574
20859
|
const tasks = ctx.store.list({ type: "task" });
|
|
20575
20860
|
const decisions = ctx.store.list({ type: "decision" });
|
|
20576
20861
|
const questions = ctx.store.list({ type: "question" });
|
|
20577
20862
|
const diagrams = getDiagramData(ctx.store);
|
|
20578
|
-
const openEpics = epics.filter((d) => !
|
|
20579
|
-
const openTasks = tasks.filter((d) => !
|
|
20863
|
+
const openEpics = epics.filter((d) => !DONE_STATUSES12.has(d.frontmatter.status));
|
|
20864
|
+
const openTasks = tasks.filter((d) => !DONE_STATUSES12.has(d.frontmatter.status));
|
|
20580
20865
|
const technicalDecisions = decisions.filter((d) => {
|
|
20581
20866
|
const tags = d.frontmatter.tags ?? [];
|
|
20582
20867
|
return tags.some((t) => {
|
|
@@ -20585,7 +20870,7 @@ function tlDashboardPage(ctx) {
|
|
|
20585
20870
|
});
|
|
20586
20871
|
});
|
|
20587
20872
|
const displayDecisions = technicalDecisions.length > 0 ? technicalDecisions : decisions;
|
|
20588
|
-
const pendingDecisions = displayDecisions.filter((d) => !
|
|
20873
|
+
const pendingDecisions = displayDecisions.filter((d) => !RESOLVED_DECISION_STATUSES2.has(d.frontmatter.status));
|
|
20589
20874
|
const statsCards = `
|
|
20590
20875
|
<div class="cards">
|
|
20591
20876
|
<div class="card">
|
|
@@ -20634,7 +20919,7 @@ function tlDashboardPage(ctx) {
|
|
|
20634
20919
|
}
|
|
20635
20920
|
|
|
20636
20921
|
// src/web/templates/pages/tl/backlog.ts
|
|
20637
|
-
var
|
|
20922
|
+
var DONE_STATUSES13 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
20638
20923
|
function tlBacklogPage(ctx) {
|
|
20639
20924
|
const epics = ctx.store.list({ type: "epic" });
|
|
20640
20925
|
const tasks = ctx.store.list({ type: "task" });
|
|
@@ -20656,10 +20941,10 @@ function tlBacklogPage(ctx) {
|
|
|
20656
20941
|
const featureIds = Array.isArray(linked) ? linked.map(String) : linked ? [String(linked)] : [];
|
|
20657
20942
|
epicFeatureMap.set(epic.frontmatter.id, featureIds);
|
|
20658
20943
|
}
|
|
20659
|
-
const
|
|
20944
|
+
const statusOrder2 = { "in-progress": 0, open: 1, draft: 2, blocked: 3, done: 4, closed: 5, resolved: 6 };
|
|
20660
20945
|
const sortedEpics = [...epics].sort((a, b) => {
|
|
20661
|
-
const sa =
|
|
20662
|
-
const sb =
|
|
20946
|
+
const sa = statusOrder2[a.frontmatter.status] ?? 3;
|
|
20947
|
+
const sb = statusOrder2[b.frontmatter.status] ?? 3;
|
|
20663
20948
|
if (sa !== sb) return sa - sb;
|
|
20664
20949
|
return a.frontmatter.id.localeCompare(b.frontmatter.id);
|
|
20665
20950
|
});
|
|
@@ -20671,7 +20956,7 @@ function tlBacklogPage(ctx) {
|
|
|
20671
20956
|
<tbody>
|
|
20672
20957
|
${sortedEpics.map((e) => {
|
|
20673
20958
|
const eTasks = epicToTasks.get(e.frontmatter.id) ?? [];
|
|
20674
|
-
const done = eTasks.filter((t) =>
|
|
20959
|
+
const done = eTasks.filter((t) => DONE_STATUSES13.has(t.frontmatter.status)).length;
|
|
20675
20960
|
const featureIds = epicFeatureMap.get(e.frontmatter.id) ?? [];
|
|
20676
20961
|
const featureLinks = featureIds.map((fid) => `<a href="/docs/feature/${escapeHtml(fid)}">${escapeHtml(fid)}</a>`).join(", ");
|
|
20677
20962
|
return `
|
|
@@ -20691,7 +20976,7 @@ function tlBacklogPage(ctx) {
|
|
|
20691
20976
|
for (const t of taskList) assignedTaskIds.add(t.frontmatter.id);
|
|
20692
20977
|
}
|
|
20693
20978
|
const unassignedTasks = tasks.filter(
|
|
20694
|
-
(t) => !assignedTaskIds.has(t.frontmatter.id) && !
|
|
20979
|
+
(t) => !assignedTaskIds.has(t.frontmatter.id) && !DONE_STATUSES13.has(t.frontmatter.status)
|
|
20695
20980
|
);
|
|
20696
20981
|
const unassignedSection = unassignedTasks.length > 0 ? collapsibleSection(
|
|
20697
20982
|
"tl-backlog-unassigned",
|
|
@@ -20752,7 +21037,6 @@ var TL_CONTRIBUTION_TYPES = /* @__PURE__ */ new Set([
|
|
|
20752
21037
|
"technical-assessment",
|
|
20753
21038
|
"architecture-review"
|
|
20754
21039
|
]);
|
|
20755
|
-
var DONE_STATUSES11 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "cancelled"]);
|
|
20756
21040
|
function progressBar4(pct) {
|
|
20757
21041
|
return `<div class="sprint-progress-bar">
|
|
20758
21042
|
<div class="sprint-progress-fill" style="width: ${pct}%"></div>
|
|
@@ -20772,25 +21056,8 @@ function tlSprintPage(ctx) {
|
|
|
20772
21056
|
<p>No active sprint found. Create a sprint and set its status to "active" to track sprint work.</p>
|
|
20773
21057
|
</div>`;
|
|
20774
21058
|
}
|
|
20775
|
-
const
|
|
20776
|
-
const
|
|
20777
|
-
for (const item of data.workItems.items) {
|
|
20778
|
-
if (techTypes.has(item.type)) {
|
|
20779
|
-
techItems.push(item);
|
|
20780
|
-
} else if (item.children) {
|
|
20781
|
-
const promoteChildren = (children) => {
|
|
20782
|
-
for (const child of children) {
|
|
20783
|
-
if (techTypes.has(child.type)) {
|
|
20784
|
-
techItems.push(child);
|
|
20785
|
-
} else if (child.children) {
|
|
20786
|
-
promoteChildren(child.children);
|
|
20787
|
-
}
|
|
20788
|
-
}
|
|
20789
|
-
};
|
|
20790
|
-
promoteChildren(item.children);
|
|
20791
|
-
}
|
|
20792
|
-
}
|
|
20793
|
-
const techDone = techItems.filter((w) => DONE_STATUSES11.has(w.status)).length;
|
|
21059
|
+
const tlItems = filterItemsByOwner(data.workItems.items, "tl");
|
|
21060
|
+
const tlCompletionPct = computeOwnerCompletionPct(data.workItems.items, "tl");
|
|
20794
21061
|
const allDocs = ctx.store.list();
|
|
20795
21062
|
const tlContributions = allDocs.filter((d) => TL_CONTRIBUTION_TYPES.has(d.frontmatter.type));
|
|
20796
21063
|
const statsCards = `
|
|
@@ -20801,9 +21068,9 @@ function tlSprintPage(ctx) {
|
|
|
20801
21068
|
<div class="card-sub">${data.timeline.daysRemaining} days remaining</div>
|
|
20802
21069
|
</div>
|
|
20803
21070
|
<div class="card">
|
|
20804
|
-
<div class="card-label">
|
|
20805
|
-
<div class="card-value">${
|
|
20806
|
-
<div class="card-sub">${
|
|
21071
|
+
<div class="card-label">TL Completion</div>
|
|
21072
|
+
<div class="card-value">${tlCompletionPct}%</div>
|
|
21073
|
+
<div class="card-sub">${tlItems.length} owned items</div>
|
|
20807
21074
|
</div>
|
|
20808
21075
|
<div class="card">
|
|
20809
21076
|
<div class="card-label">Epics</div>
|
|
@@ -20821,28 +21088,10 @@ function tlSprintPage(ctx) {
|
|
|
20821
21088
|
<strong>${escapeHtml(data.sprint.id)} \u2014 ${escapeHtml(data.sprint.title)}</strong>
|
|
20822
21089
|
${data.sprint.goal ? ` | ${escapeHtml(data.sprint.goal)}` : ""}
|
|
20823
21090
|
</div>`;
|
|
20824
|
-
const workItemsSection =
|
|
20825
|
-
"tl-sprint-items",
|
|
20826
|
-
|
|
20827
|
-
|
|
20828
|
-
<table>
|
|
20829
|
-
<thead>
|
|
20830
|
-
<tr><th>ID</th><th>Title</th><th>Type</th><th>Status</th><th>Focus</th></tr>
|
|
20831
|
-
</thead>
|
|
20832
|
-
<tbody>
|
|
20833
|
-
${techItems.map((w) => `
|
|
20834
|
-
<tr>
|
|
20835
|
-
<td><a href="/docs/${escapeHtml(w.type)}/${escapeHtml(w.id)}">${escapeHtml(w.id)}</a></td>
|
|
20836
|
-
<td>${escapeHtml(w.title)}</td>
|
|
20837
|
-
<td>${escapeHtml(typeLabel(w.type))}</td>
|
|
20838
|
-
<td>${statusBadge(w.status)}</td>
|
|
20839
|
-
<td>${w.workFocus ? `<span class="badge badge-subtle">${escapeHtml(w.workFocus)}</span>` : '<span class="text-dim">\u2014</span>'}</td>
|
|
20840
|
-
</tr>`).join("")}
|
|
20841
|
-
</tbody>
|
|
20842
|
-
</table>
|
|
20843
|
-
</div>`,
|
|
20844
|
-
{ titleTag: "h3" }
|
|
20845
|
-
) : "";
|
|
21091
|
+
const workItemsSection = renderWorkItemsTable(tlItems, {
|
|
21092
|
+
sectionId: "tl-sprint-items",
|
|
21093
|
+
title: "TL Work Items"
|
|
21094
|
+
});
|
|
20846
21095
|
const contributionsSection = tlContributions.length > 0 ? collapsibleSection(
|
|
20847
21096
|
"tl-sprint-contributions",
|
|
20848
21097
|
`TL Contributions (${tlContributions.length})`,
|
|
@@ -21226,93 +21475,273 @@ function upcomingPage(data) {
|
|
|
21226
21475
|
`;
|
|
21227
21476
|
}
|
|
21228
21477
|
|
|
21229
|
-
// src/web/templates/pages/sprint-
|
|
21230
|
-
function
|
|
21478
|
+
// src/web/templates/pages/sprint-summary.ts
|
|
21479
|
+
function progressBar5(pct) {
|
|
21480
|
+
return `<div class="sprint-progress-bar">
|
|
21481
|
+
<div class="sprint-progress-fill" style="width: ${pct}%"></div>
|
|
21482
|
+
<span class="sprint-progress-label">${pct}%</span>
|
|
21483
|
+
</div>`;
|
|
21484
|
+
}
|
|
21485
|
+
function sprintSummaryPage(data, cached2) {
|
|
21231
21486
|
if (!data) {
|
|
21232
21487
|
return `
|
|
21233
21488
|
<div class="page-header">
|
|
21234
|
-
<h2>Sprint
|
|
21235
|
-
<div class="subtitle">
|
|
21489
|
+
<h2>Sprint Summary</h2>
|
|
21490
|
+
<div class="subtitle">AI-powered sprint narrative</div>
|
|
21236
21491
|
</div>
|
|
21237
21492
|
<div class="empty">
|
|
21238
21493
|
<h3>No Active Sprint</h3>
|
|
21239
|
-
<p>No active sprint found.</p>
|
|
21494
|
+
<p>No active sprint found. Create a sprint and set its status to "active" to see the summary.</p>
|
|
21240
21495
|
</div>`;
|
|
21241
21496
|
}
|
|
21242
|
-
const blockerDocs = data.blockers.map((b) => {
|
|
21243
|
-
const doc = store.get(b.id);
|
|
21244
|
-
return { ...b, doc };
|
|
21245
|
-
});
|
|
21246
21497
|
const statsCards = `
|
|
21247
21498
|
<div class="cards">
|
|
21248
21499
|
<div class="card">
|
|
21249
|
-
<div class="card-label">
|
|
21250
|
-
<div class="card-value
|
|
21251
|
-
<div class="card-sub"
|
|
21500
|
+
<div class="card-label">Completion</div>
|
|
21501
|
+
<div class="card-value">${data.workItems.completionPct}%</div>
|
|
21502
|
+
<div class="card-sub">${data.workItems.done} / ${data.workItems.total} items done</div>
|
|
21252
21503
|
</div>
|
|
21253
|
-
|
|
21254
|
-
|
|
21255
|
-
|
|
21256
|
-
|
|
21257
|
-
const assignee = doc?.frontmatter.assignee;
|
|
21258
|
-
const content = doc?.content?.trim();
|
|
21259
|
-
return `
|
|
21260
|
-
<div class="blocker-card">
|
|
21261
|
-
<div class="blocker-card-header">
|
|
21262
|
-
<a href="/docs/${escapeHtml(b.type)}/${escapeHtml(b.id)}">${escapeHtml(b.id)}</a>
|
|
21263
|
-
<span class="text-dim">${escapeHtml(typeLabel(b.type))}</span>
|
|
21264
|
-
${statusBadge("blocked")}
|
|
21265
|
-
</div>
|
|
21266
|
-
<h4 class="blocker-card-title">${escapeHtml(b.title)}</h4>
|
|
21267
|
-
<div class="blocker-card-meta">
|
|
21268
|
-
${owner ? `<span><strong>Owner:</strong> ${escapeHtml(owner)}</span>` : ""}
|
|
21269
|
-
${assignee ? `<span><strong>Assignee:</strong> ${escapeHtml(assignee)}</span>` : ""}
|
|
21270
|
-
${doc?.frontmatter.created ? `<span><strong>Created:</strong> ${formatDate(doc.frontmatter.created)}</span>` : ""}
|
|
21271
|
-
</div>
|
|
21272
|
-
${content ? `<div class="blocker-card-content detail-content">${renderMarkdown(content)}</div>` : ""}
|
|
21273
|
-
</div>`;
|
|
21274
|
-
}).join("");
|
|
21275
|
-
const emptyMessage = blockerDocs.length === 0 ? `<div class="empty"><h3>No Blockers</h3><p>No blocked items in this sprint.</p></div>` : "";
|
|
21276
|
-
return `
|
|
21277
|
-
<div class="page-header">
|
|
21278
|
-
<h2>Sprint Blockers</h2>
|
|
21279
|
-
<div class="subtitle">Blocked items in ${escapeHtml(data.sprint.id)} \u2014 ${escapeHtml(data.sprint.title)}</div>
|
|
21280
|
-
</div>
|
|
21281
|
-
${statsCards}
|
|
21282
|
-
${emptyMessage}
|
|
21283
|
-
${itemCards}`;
|
|
21284
|
-
}
|
|
21285
|
-
|
|
21286
|
-
// src/web/templates/pages/sprint-risks.ts
|
|
21287
|
-
function sprintRisksPage(data, store) {
|
|
21288
|
-
if (!data) {
|
|
21289
|
-
return `
|
|
21290
|
-
<div class="page-header">
|
|
21291
|
-
<h2>Sprint Risks</h2>
|
|
21292
|
-
<div class="subtitle">Risk items in the active sprint</div>
|
|
21504
|
+
<div class="card">
|
|
21505
|
+
<div class="card-label">Days Remaining</div>
|
|
21506
|
+
<div class="card-value">${data.timeline.daysRemaining}</div>
|
|
21507
|
+
<div class="card-sub">${data.timeline.daysElapsed} of ${data.timeline.totalDays} elapsed</div>
|
|
21293
21508
|
</div>
|
|
21294
|
-
<div class="empty">
|
|
21295
|
-
<h3>No Active Sprint</h3>
|
|
21296
|
-
<p>No active sprint found.</p>
|
|
21297
|
-
</div>`;
|
|
21298
|
-
}
|
|
21299
|
-
const riskDocs = data.risks.map((r) => {
|
|
21300
|
-
const doc = store.get(r.id);
|
|
21301
|
-
return { ...r, doc };
|
|
21302
|
-
});
|
|
21303
|
-
const statsCards = `
|
|
21304
|
-
<div class="cards">
|
|
21305
21509
|
<div class="card">
|
|
21306
|
-
<div class="card-label">
|
|
21307
|
-
<div class="card-value
|
|
21308
|
-
<div class="card-sub">
|
|
21510
|
+
<div class="card-label">Epics</div>
|
|
21511
|
+
<div class="card-value">${data.linkedEpics.length}</div>
|
|
21512
|
+
<div class="card-sub">linked to sprint</div>
|
|
21309
21513
|
</div>
|
|
21310
|
-
|
|
21311
|
-
|
|
21312
|
-
|
|
21313
|
-
|
|
21314
|
-
|
|
21315
|
-
|
|
21514
|
+
<a class="card card-link" href="sprint-blockers">
|
|
21515
|
+
<div class="card-label">Blockers</div>
|
|
21516
|
+
<div class="card-value${data.blockers.length > 0 ? " priority-high" : ""}">${data.blockers.length}</div>
|
|
21517
|
+
<div class="card-sub">${data.workItems.blocked} blocked items</div>
|
|
21518
|
+
</a>
|
|
21519
|
+
<a class="card card-link" href="sprint-risks">
|
|
21520
|
+
<div class="card-label">Risks</div>
|
|
21521
|
+
<div class="card-value${data.risks.length > 0 ? " priority-medium" : ""}">${data.risks.length}</div>
|
|
21522
|
+
<div class="card-sub">open risk items</div>
|
|
21523
|
+
</a>
|
|
21524
|
+
</div>`;
|
|
21525
|
+
const epicsTable = data.linkedEpics.length > 0 ? collapsibleSection(
|
|
21526
|
+
"ss-epics",
|
|
21527
|
+
"Linked Epics",
|
|
21528
|
+
`<div class="table-wrap">
|
|
21529
|
+
<table>
|
|
21530
|
+
<thead>
|
|
21531
|
+
<tr><th>ID</th><th>Title</th><th>Status</th><th>Tasks</th></tr>
|
|
21532
|
+
</thead>
|
|
21533
|
+
<tbody>
|
|
21534
|
+
${data.linkedEpics.map((e) => `
|
|
21535
|
+
<tr>
|
|
21536
|
+
<td><a href="/docs/epic/${escapeHtml(e.id)}">${escapeHtml(e.id)}</a></td>
|
|
21537
|
+
<td>${escapeHtml(e.title)}</td>
|
|
21538
|
+
<td>${statusBadge(e.status)}</td>
|
|
21539
|
+
<td>${e.tasksDone} / ${e.tasksTotal}</td>
|
|
21540
|
+
</tr>`).join("")}
|
|
21541
|
+
</tbody>
|
|
21542
|
+
</table>
|
|
21543
|
+
</div>`,
|
|
21544
|
+
{ titleTag: "h3" }
|
|
21545
|
+
) : "";
|
|
21546
|
+
const workItemsSection = renderWorkItemsTable(data.workItems.items, {
|
|
21547
|
+
sectionId: "ss-work-items",
|
|
21548
|
+
title: "Work Items",
|
|
21549
|
+
defaultCollapsed: true
|
|
21550
|
+
});
|
|
21551
|
+
const activitySection = data.artifacts.length > 0 ? collapsibleSection(
|
|
21552
|
+
"ss-activity",
|
|
21553
|
+
"Recent Activity",
|
|
21554
|
+
`<div class="table-wrap">
|
|
21555
|
+
<table>
|
|
21556
|
+
<thead>
|
|
21557
|
+
<tr><th>Date</th><th>ID</th><th>Title</th><th>Type</th><th>Action</th></tr>
|
|
21558
|
+
</thead>
|
|
21559
|
+
<tbody>
|
|
21560
|
+
${data.artifacts.slice(0, 15).map((a) => `
|
|
21561
|
+
<tr>
|
|
21562
|
+
<td>${formatDate(a.date)}</td>
|
|
21563
|
+
<td><a href="/docs/${escapeHtml(a.type)}/${escapeHtml(a.id)}">${escapeHtml(a.id)}</a></td>
|
|
21564
|
+
<td>${escapeHtml(a.title)}</td>
|
|
21565
|
+
<td>${escapeHtml(typeLabel(a.type))}</td>
|
|
21566
|
+
<td>${escapeHtml(a.action)}</td>
|
|
21567
|
+
</tr>`).join("")}
|
|
21568
|
+
</tbody>
|
|
21569
|
+
</table>
|
|
21570
|
+
</div>`,
|
|
21571
|
+
{ titleTag: "h3", defaultCollapsed: true }
|
|
21572
|
+
) : "";
|
|
21573
|
+
const meetingsSection = data.meetings.length > 0 ? collapsibleSection(
|
|
21574
|
+
"ss-meetings",
|
|
21575
|
+
`Meetings (${data.meetings.length})`,
|
|
21576
|
+
`<div class="table-wrap">
|
|
21577
|
+
<table>
|
|
21578
|
+
<thead>
|
|
21579
|
+
<tr><th>Date</th><th>ID</th><th>Title</th></tr>
|
|
21580
|
+
</thead>
|
|
21581
|
+
<tbody>
|
|
21582
|
+
${data.meetings.map((m) => `
|
|
21583
|
+
<tr>
|
|
21584
|
+
<td>${formatDate(m.date)}</td>
|
|
21585
|
+
<td><a href="/docs/meeting/${escapeHtml(m.id)}">${escapeHtml(m.id)}</a></td>
|
|
21586
|
+
<td>${escapeHtml(m.title)}</td>
|
|
21587
|
+
</tr>`).join("")}
|
|
21588
|
+
</tbody>
|
|
21589
|
+
</table>
|
|
21590
|
+
</div>`,
|
|
21591
|
+
{ titleTag: "h3", defaultCollapsed: true }
|
|
21592
|
+
) : "";
|
|
21593
|
+
const goalHtml = data.sprint.goal ? `<div class="sprint-goal"><strong>Goal:</strong> ${escapeHtml(data.sprint.goal)}</div>` : "";
|
|
21594
|
+
const dateRange = data.sprint.startDate && data.sprint.endDate ? `<span class="text-dim">${formatDate(data.sprint.startDate)} \u2014 ${formatDate(data.sprint.endDate)}</span>` : "";
|
|
21595
|
+
return `
|
|
21596
|
+
<div class="page-header">
|
|
21597
|
+
<h2>${escapeHtml(data.sprint.id)} \u2014 ${escapeHtml(data.sprint.title)} ${statusBadge(data.sprint.status)}</h2>
|
|
21598
|
+
<div class="subtitle">Sprint Summary ${dateRange}</div>
|
|
21599
|
+
</div>
|
|
21600
|
+
${goalHtml}
|
|
21601
|
+
${progressBar5(data.timeline.percentComplete)}
|
|
21602
|
+
${statsCards}
|
|
21603
|
+
${epicsTable}
|
|
21604
|
+
${workItemsSection}
|
|
21605
|
+
${activitySection}
|
|
21606
|
+
${meetingsSection}
|
|
21607
|
+
|
|
21608
|
+
<div class="sprint-ai-section">
|
|
21609
|
+
<h3>AI Summary</h3>
|
|
21610
|
+
${cached2 ? `<p class="text-dim">Generated ${formatDate(cached2.generatedAt)} at ${cached2.generatedAt.slice(11, 16)} UTC</p>` : `<p class="text-dim">Generate a narrative summary of this sprint's progress, risks, and projections.</p>`}
|
|
21611
|
+
<button class="sprint-generate-btn" onclick="generateSummary()" id="generate-btn">${cached2 ? "Regenerate" : "Generate AI Summary"}</button>
|
|
21612
|
+
<div id="summary-loading" class="sprint-loading" style="display:none">
|
|
21613
|
+
<div class="sprint-spinner"></div>
|
|
21614
|
+
<span>Generating summary...</span>
|
|
21615
|
+
</div>
|
|
21616
|
+
<div id="summary-error" class="sprint-error" style="display:none"></div>
|
|
21617
|
+
<div id="summary-content" class="detail-content"${cached2 ? "" : ' style="display:none"'}>${cached2 ? cached2.html : ""}</div>
|
|
21618
|
+
</div>
|
|
21619
|
+
|
|
21620
|
+
<script>
|
|
21621
|
+
async function generateSummary() {
|
|
21622
|
+
var btn = document.getElementById('generate-btn');
|
|
21623
|
+
var loading = document.getElementById('summary-loading');
|
|
21624
|
+
var errorEl = document.getElementById('summary-error');
|
|
21625
|
+
var content = document.getElementById('summary-content');
|
|
21626
|
+
|
|
21627
|
+
btn.disabled = true;
|
|
21628
|
+
btn.style.display = 'none';
|
|
21629
|
+
loading.style.display = 'flex';
|
|
21630
|
+
errorEl.style.display = 'none';
|
|
21631
|
+
content.style.display = 'none';
|
|
21632
|
+
|
|
21633
|
+
try {
|
|
21634
|
+
var res = await fetch('/api/sprint-summary', {
|
|
21635
|
+
method: 'POST',
|
|
21636
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21637
|
+
body: JSON.stringify({ sprintId: '${escapeHtml(data.sprint.id)}' })
|
|
21638
|
+
});
|
|
21639
|
+
var json = await res.json();
|
|
21640
|
+
if (!res.ok) throw new Error(json.error || 'Failed to generate summary');
|
|
21641
|
+
loading.style.display = 'none';
|
|
21642
|
+
content.innerHTML = json.html;
|
|
21643
|
+
content.style.display = 'block';
|
|
21644
|
+
btn.textContent = 'Regenerate';
|
|
21645
|
+
btn.style.display = '';
|
|
21646
|
+
btn.disabled = false;
|
|
21647
|
+
} catch (e) {
|
|
21648
|
+
loading.style.display = 'none';
|
|
21649
|
+
errorEl.textContent = e.message;
|
|
21650
|
+
errorEl.style.display = 'block';
|
|
21651
|
+
btn.style.display = '';
|
|
21652
|
+
btn.disabled = false;
|
|
21653
|
+
}
|
|
21654
|
+
}
|
|
21655
|
+
</script>`;
|
|
21656
|
+
}
|
|
21657
|
+
|
|
21658
|
+
// src/web/templates/pages/sprint-blockers.ts
|
|
21659
|
+
function sprintBlockersPage(data, store) {
|
|
21660
|
+
if (!data) {
|
|
21661
|
+
return `
|
|
21662
|
+
<div class="page-header">
|
|
21663
|
+
<h2>Sprint Blockers</h2>
|
|
21664
|
+
<div class="subtitle">Blocked items in the active sprint</div>
|
|
21665
|
+
</div>
|
|
21666
|
+
<div class="empty">
|
|
21667
|
+
<h3>No Active Sprint</h3>
|
|
21668
|
+
<p>No active sprint found.</p>
|
|
21669
|
+
</div>`;
|
|
21670
|
+
}
|
|
21671
|
+
const blockerDocs = data.blockers.map((b) => {
|
|
21672
|
+
const doc = store.get(b.id);
|
|
21673
|
+
return { ...b, doc };
|
|
21674
|
+
});
|
|
21675
|
+
const statsCards = `
|
|
21676
|
+
<div class="cards">
|
|
21677
|
+
<div class="card">
|
|
21678
|
+
<div class="card-label">Blocked Items</div>
|
|
21679
|
+
<div class="card-value${blockerDocs.length > 0 ? " priority-high" : ""}">${blockerDocs.length}</div>
|
|
21680
|
+
<div class="card-sub">in ${escapeHtml(data.sprint.id)}</div>
|
|
21681
|
+
</div>
|
|
21682
|
+
</div>`;
|
|
21683
|
+
const itemCards = blockerDocs.map((b) => {
|
|
21684
|
+
const doc = b.doc;
|
|
21685
|
+
const owner = doc?.frontmatter.owner;
|
|
21686
|
+
const assignee = doc?.frontmatter.assignee;
|
|
21687
|
+
const content = doc?.content?.trim();
|
|
21688
|
+
return `
|
|
21689
|
+
<div class="blocker-card">
|
|
21690
|
+
<div class="blocker-card-header">
|
|
21691
|
+
<a href="/docs/${escapeHtml(b.type)}/${escapeHtml(b.id)}">${escapeHtml(b.id)}</a>
|
|
21692
|
+
<span class="text-dim">${escapeHtml(typeLabel(b.type))}</span>
|
|
21693
|
+
${statusBadge("blocked")}
|
|
21694
|
+
</div>
|
|
21695
|
+
<h4 class="blocker-card-title">${escapeHtml(b.title)}</h4>
|
|
21696
|
+
<div class="blocker-card-meta">
|
|
21697
|
+
${owner ? `<span><strong>Owner:</strong> ${escapeHtml(owner)}</span>` : ""}
|
|
21698
|
+
${assignee ? `<span><strong>Assignee:</strong> ${escapeHtml(assignee)}</span>` : ""}
|
|
21699
|
+
${doc?.frontmatter.created ? `<span><strong>Created:</strong> ${formatDate(doc.frontmatter.created)}</span>` : ""}
|
|
21700
|
+
</div>
|
|
21701
|
+
${content ? `<div class="blocker-card-content detail-content">${renderMarkdown(content)}</div>` : ""}
|
|
21702
|
+
</div>`;
|
|
21703
|
+
}).join("");
|
|
21704
|
+
const emptyMessage = blockerDocs.length === 0 ? `<div class="empty"><h3>No Blockers</h3><p>No blocked items in this sprint.</p></div>` : "";
|
|
21705
|
+
return `
|
|
21706
|
+
<div class="page-header">
|
|
21707
|
+
<h2>Sprint Blockers</h2>
|
|
21708
|
+
<div class="subtitle">Blocked items in ${escapeHtml(data.sprint.id)} \u2014 ${escapeHtml(data.sprint.title)}</div>
|
|
21709
|
+
</div>
|
|
21710
|
+
${statsCards}
|
|
21711
|
+
${emptyMessage}
|
|
21712
|
+
${itemCards}`;
|
|
21713
|
+
}
|
|
21714
|
+
|
|
21715
|
+
// src/web/templates/pages/sprint-risks.ts
|
|
21716
|
+
function sprintRisksPage(data, store) {
|
|
21717
|
+
if (!data) {
|
|
21718
|
+
return `
|
|
21719
|
+
<div class="page-header">
|
|
21720
|
+
<h2>Sprint Risks</h2>
|
|
21721
|
+
<div class="subtitle">Risk items in the active sprint</div>
|
|
21722
|
+
</div>
|
|
21723
|
+
<div class="empty">
|
|
21724
|
+
<h3>No Active Sprint</h3>
|
|
21725
|
+
<p>No active sprint found.</p>
|
|
21726
|
+
</div>`;
|
|
21727
|
+
}
|
|
21728
|
+
const riskDocs = data.risks.map((r) => {
|
|
21729
|
+
const doc = store.get(r.id);
|
|
21730
|
+
return { ...r, doc };
|
|
21731
|
+
});
|
|
21732
|
+
const statsCards = `
|
|
21733
|
+
<div class="cards">
|
|
21734
|
+
<div class="card">
|
|
21735
|
+
<div class="card-label">Open Risks</div>
|
|
21736
|
+
<div class="card-value${riskDocs.length > 0 ? " priority-medium" : ""}">${riskDocs.length}</div>
|
|
21737
|
+
<div class="card-sub">in ${escapeHtml(data.sprint.id)}</div>
|
|
21738
|
+
</div>
|
|
21739
|
+
</div>`;
|
|
21740
|
+
const itemCards = riskDocs.map((r) => {
|
|
21741
|
+
const doc = r.doc;
|
|
21742
|
+
const owner = doc?.frontmatter.owner;
|
|
21743
|
+
const assignee = doc?.frontmatter.assignee;
|
|
21744
|
+
const content = doc?.content?.trim();
|
|
21316
21745
|
return `
|
|
21317
21746
|
<div class="blocker-card" id="risk-${escapeHtml(r.id)}">
|
|
21318
21747
|
<div class="blocker-card-header">
|
|
@@ -24466,13 +24895,23 @@ import { tool as tool20 } from "@anthropic-ai/claude-agent-sdk";
|
|
|
24466
24895
|
// src/skills/builtin/jira/client.ts
|
|
24467
24896
|
var JiraClient = class {
|
|
24468
24897
|
baseUrl;
|
|
24898
|
+
baseUrlV3;
|
|
24469
24899
|
authHeader;
|
|
24470
24900
|
constructor(config2) {
|
|
24471
|
-
|
|
24901
|
+
const host = config2.host.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
24902
|
+
this.baseUrl = `https://${host}/rest/api/2`;
|
|
24903
|
+
this.baseUrlV3 = `https://${host}/rest/api/3`;
|
|
24472
24904
|
this.authHeader = "Basic " + Buffer.from(`${config2.email}:${config2.apiToken}`).toString("base64");
|
|
24473
24905
|
}
|
|
24474
24906
|
async request(path21, method = "GET", body) {
|
|
24475
24907
|
const url2 = `${this.baseUrl}${path21}`;
|
|
24908
|
+
return this.doRequest(url2, method, body);
|
|
24909
|
+
}
|
|
24910
|
+
async requestV3(path21, method = "GET", body) {
|
|
24911
|
+
const url2 = `${this.baseUrlV3}${path21}`;
|
|
24912
|
+
return this.doRequest(url2, method, body);
|
|
24913
|
+
}
|
|
24914
|
+
async doRequest(url2, method, body) {
|
|
24476
24915
|
const headers = {
|
|
24477
24916
|
Authorization: this.authHeader,
|
|
24478
24917
|
"Content-Type": "application/json",
|
|
@@ -24486,7 +24925,7 @@ var JiraClient = class {
|
|
|
24486
24925
|
if (!response.ok) {
|
|
24487
24926
|
const text = await response.text().catch(() => "");
|
|
24488
24927
|
throw new Error(
|
|
24489
|
-
`Jira API error ${response.status} ${method} ${
|
|
24928
|
+
`Jira API error ${response.status} ${method} ${url2}: ${text}`
|
|
24490
24929
|
);
|
|
24491
24930
|
}
|
|
24492
24931
|
if (response.status === 204) return void 0;
|
|
@@ -24499,6 +24938,14 @@ var JiraClient = class {
|
|
|
24499
24938
|
});
|
|
24500
24939
|
return this.request(`/search?${params}`);
|
|
24501
24940
|
}
|
|
24941
|
+
async searchIssuesV3(jql, fields = ["summary", "status", "issuetype", "priority", "assignee", "labels"], maxResults = 50) {
|
|
24942
|
+
const params = new URLSearchParams({
|
|
24943
|
+
jql,
|
|
24944
|
+
maxResults: String(maxResults),
|
|
24945
|
+
fields: fields.join(",")
|
|
24946
|
+
});
|
|
24947
|
+
return this.requestV3(`/search/jql?${params}`);
|
|
24948
|
+
}
|
|
24502
24949
|
async getIssue(key) {
|
|
24503
24950
|
return this.request(`/issue/${encodeURIComponent(key)}`);
|
|
24504
24951
|
}
|
|
@@ -24512,6 +24959,28 @@ var JiraClient = class {
|
|
|
24512
24959
|
{ fields }
|
|
24513
24960
|
);
|
|
24514
24961
|
}
|
|
24962
|
+
async getIssueWithLinks(key) {
|
|
24963
|
+
return this.request(
|
|
24964
|
+
`/issue/${encodeURIComponent(key)}?fields=summary,status,issuetype,priority,assignee,labels,subtasks,issuelinks`
|
|
24965
|
+
);
|
|
24966
|
+
}
|
|
24967
|
+
async getChangelog(key) {
|
|
24968
|
+
const result = await this.request(
|
|
24969
|
+
`/issue/${encodeURIComponent(key)}/changelog?maxResults=100`
|
|
24970
|
+
);
|
|
24971
|
+
return result.values;
|
|
24972
|
+
}
|
|
24973
|
+
async getComments(key) {
|
|
24974
|
+
const result = await this.request(
|
|
24975
|
+
`/issue/${encodeURIComponent(key)}/comment?maxResults=100`
|
|
24976
|
+
);
|
|
24977
|
+
return result.comments;
|
|
24978
|
+
}
|
|
24979
|
+
async getRemoteLinks(key) {
|
|
24980
|
+
return this.request(
|
|
24981
|
+
`/issue/${encodeURIComponent(key)}/remotelink`
|
|
24982
|
+
);
|
|
24983
|
+
}
|
|
24515
24984
|
async addComment(key, body) {
|
|
24516
24985
|
await this.request(
|
|
24517
24986
|
`/issue/${encodeURIComponent(key)}/comment`,
|
|
@@ -24525,7 +24994,651 @@ function createJiraClient(jiraUserConfig) {
|
|
|
24525
24994
|
const email3 = jiraUserConfig?.email ?? process.env.JIRA_EMAIL;
|
|
24526
24995
|
const apiToken = jiraUserConfig?.apiToken ?? process.env.JIRA_API_TOKEN;
|
|
24527
24996
|
if (!host || !email3 || !apiToken) return null;
|
|
24528
|
-
|
|
24997
|
+
const normalizedHost = host.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
24998
|
+
return { client: new JiraClient({ host, email: email3, apiToken }), host: normalizedHost };
|
|
24999
|
+
}
|
|
25000
|
+
|
|
25001
|
+
// src/skills/builtin/jira/sync.ts
|
|
25002
|
+
var DONE_STATUSES14 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "obsolete", "wont do"]);
|
|
25003
|
+
var DEFAULT_ACTION_STATUS_MAP = {
|
|
25004
|
+
done: ["Done", "Closed", "Resolved", "Obsolete", "Wont Do"],
|
|
25005
|
+
"in-progress": ["In Progress", "In Review", "Reviewing", "Testing"],
|
|
25006
|
+
blocked: ["Blocked"],
|
|
25007
|
+
open: ["To Do", "Open", "Backlog", "New"]
|
|
25008
|
+
};
|
|
25009
|
+
var DEFAULT_TASK_STATUS_MAP = {
|
|
25010
|
+
done: ["Done", "Closed", "Resolved", "Obsolete", "Wont Do"],
|
|
25011
|
+
review: ["In Review", "Code Review", "Reviewing", "Testing"],
|
|
25012
|
+
"in-progress": ["In Progress"],
|
|
25013
|
+
ready: ["Ready", "Selected for Development"],
|
|
25014
|
+
blocked: ["Blocked"],
|
|
25015
|
+
backlog: ["To Do", "Open", "Backlog", "New"]
|
|
25016
|
+
};
|
|
25017
|
+
function buildStatusLookup(configMap, defaults) {
|
|
25018
|
+
const map2 = configMap ?? defaults;
|
|
25019
|
+
const lookup = /* @__PURE__ */ new Map();
|
|
25020
|
+
for (const [marvinStatus, jiraStatuses] of Object.entries(map2)) {
|
|
25021
|
+
for (const js of jiraStatuses) {
|
|
25022
|
+
lookup.set(js.toLowerCase(), marvinStatus);
|
|
25023
|
+
}
|
|
25024
|
+
}
|
|
25025
|
+
return lookup;
|
|
25026
|
+
}
|
|
25027
|
+
function mapJiraStatusForAction(status, configMap) {
|
|
25028
|
+
const lookup = buildStatusLookup(configMap, DEFAULT_ACTION_STATUS_MAP);
|
|
25029
|
+
return lookup.get(status.toLowerCase()) ?? "open";
|
|
25030
|
+
}
|
|
25031
|
+
function mapJiraStatusForTask(status, configMap) {
|
|
25032
|
+
const lookup = buildStatusLookup(configMap, DEFAULT_TASK_STATUS_MAP);
|
|
25033
|
+
return lookup.get(status.toLowerCase()) ?? "backlog";
|
|
25034
|
+
}
|
|
25035
|
+
function computeSubtaskProgress(subtasks) {
|
|
25036
|
+
if (subtasks.length === 0) return 0;
|
|
25037
|
+
const done = subtasks.filter(
|
|
25038
|
+
(s) => DONE_STATUSES14.has(s.fields.status.name.toLowerCase())
|
|
25039
|
+
).length;
|
|
25040
|
+
return Math.round(done / subtasks.length * 100);
|
|
25041
|
+
}
|
|
25042
|
+
async function fetchJiraStatus(store, client, host, artifactId, statusMap) {
|
|
25043
|
+
const result = { artifacts: [], errors: [] };
|
|
25044
|
+
const actions = store.list({ type: "action" });
|
|
25045
|
+
const tasks = store.list({ type: "task" });
|
|
25046
|
+
let candidates = [...actions, ...tasks].filter(
|
|
25047
|
+
(d) => d.frontmatter.jiraKey
|
|
25048
|
+
);
|
|
25049
|
+
if (artifactId) {
|
|
25050
|
+
candidates = candidates.filter((d) => d.frontmatter.id === artifactId);
|
|
25051
|
+
if (candidates.length === 0) {
|
|
25052
|
+
const doc = store.get(artifactId);
|
|
25053
|
+
if (doc) {
|
|
25054
|
+
result.errors.push(
|
|
25055
|
+
`${artifactId} has no jiraKey \u2014 use push_artifact_to_jira or link_to_jira first`
|
|
25056
|
+
);
|
|
25057
|
+
} else {
|
|
25058
|
+
result.errors.push(`Artifact ${artifactId} not found`);
|
|
25059
|
+
}
|
|
25060
|
+
return result;
|
|
25061
|
+
}
|
|
25062
|
+
}
|
|
25063
|
+
candidates = candidates.filter(
|
|
25064
|
+
(d) => !DONE_STATUSES14.has(d.frontmatter.status)
|
|
25065
|
+
);
|
|
25066
|
+
for (const doc of candidates) {
|
|
25067
|
+
const jiraKey = doc.frontmatter.jiraKey;
|
|
25068
|
+
const artifactType = doc.frontmatter.type;
|
|
25069
|
+
try {
|
|
25070
|
+
const issue2 = await client.getIssueWithLinks(jiraKey);
|
|
25071
|
+
const proposedStatus = artifactType === "task" ? mapJiraStatusForTask(issue2.fields.status.name, statusMap?.task) : mapJiraStatusForAction(issue2.fields.status.name, statusMap?.action);
|
|
25072
|
+
const currentStatus = doc.frontmatter.status;
|
|
25073
|
+
const linkedIssues = [];
|
|
25074
|
+
if (issue2.fields.subtasks) {
|
|
25075
|
+
for (const sub of issue2.fields.subtasks) {
|
|
25076
|
+
linkedIssues.push({
|
|
25077
|
+
key: sub.key,
|
|
25078
|
+
summary: sub.fields.summary,
|
|
25079
|
+
status: sub.fields.status.name,
|
|
25080
|
+
relationship: "subtask",
|
|
25081
|
+
isDone: DONE_STATUSES14.has(sub.fields.status.name.toLowerCase())
|
|
25082
|
+
});
|
|
25083
|
+
}
|
|
25084
|
+
}
|
|
25085
|
+
if (issue2.fields.issuelinks) {
|
|
25086
|
+
for (const link of issue2.fields.issuelinks) {
|
|
25087
|
+
if (link.outwardIssue) {
|
|
25088
|
+
linkedIssues.push({
|
|
25089
|
+
key: link.outwardIssue.key,
|
|
25090
|
+
summary: link.outwardIssue.fields.summary,
|
|
25091
|
+
status: link.outwardIssue.fields.status.name,
|
|
25092
|
+
relationship: link.type.outward,
|
|
25093
|
+
isDone: DONE_STATUSES14.has(
|
|
25094
|
+
link.outwardIssue.fields.status.name.toLowerCase()
|
|
25095
|
+
)
|
|
25096
|
+
});
|
|
25097
|
+
}
|
|
25098
|
+
if (link.inwardIssue) {
|
|
25099
|
+
linkedIssues.push({
|
|
25100
|
+
key: link.inwardIssue.key,
|
|
25101
|
+
summary: link.inwardIssue.fields.summary,
|
|
25102
|
+
status: link.inwardIssue.fields.status.name,
|
|
25103
|
+
relationship: link.type.inward,
|
|
25104
|
+
isDone: DONE_STATUSES14.has(
|
|
25105
|
+
link.inwardIssue.fields.status.name.toLowerCase()
|
|
25106
|
+
)
|
|
25107
|
+
});
|
|
25108
|
+
}
|
|
25109
|
+
}
|
|
25110
|
+
}
|
|
25111
|
+
const subtasks = issue2.fields.subtasks ?? [];
|
|
25112
|
+
let proposedProgress;
|
|
25113
|
+
if (subtasks.length > 0 && !doc.frontmatter.progressOverride) {
|
|
25114
|
+
proposedProgress = computeSubtaskProgress(subtasks);
|
|
25115
|
+
}
|
|
25116
|
+
const currentProgress = doc.frontmatter.progress;
|
|
25117
|
+
result.artifacts.push({
|
|
25118
|
+
id: doc.frontmatter.id,
|
|
25119
|
+
type: artifactType,
|
|
25120
|
+
jiraKey,
|
|
25121
|
+
jiraUrl: `https://${host}/browse/${jiraKey}`,
|
|
25122
|
+
jiraSummary: issue2.fields.summary,
|
|
25123
|
+
jiraStatus: issue2.fields.status.name,
|
|
25124
|
+
currentMarvinStatus: currentStatus,
|
|
25125
|
+
proposedMarvinStatus: proposedStatus,
|
|
25126
|
+
statusChanged: currentStatus !== proposedStatus,
|
|
25127
|
+
currentProgress,
|
|
25128
|
+
proposedProgress,
|
|
25129
|
+
progressChanged: proposedProgress !== void 0 && proposedProgress !== currentProgress,
|
|
25130
|
+
linkedIssues
|
|
25131
|
+
});
|
|
25132
|
+
} catch (err) {
|
|
25133
|
+
result.errors.push(
|
|
25134
|
+
`${doc.frontmatter.id} (${jiraKey}): ${err instanceof Error ? err.message : String(err)}`
|
|
25135
|
+
);
|
|
25136
|
+
}
|
|
25137
|
+
}
|
|
25138
|
+
return result;
|
|
25139
|
+
}
|
|
25140
|
+
async function syncJiraProgress(store, client, host, artifactId, statusMap) {
|
|
25141
|
+
const fetchResult = await fetchJiraStatus(store, client, host, artifactId, statusMap);
|
|
25142
|
+
const result = {
|
|
25143
|
+
updated: [],
|
|
25144
|
+
unchanged: 0,
|
|
25145
|
+
errors: [...fetchResult.errors]
|
|
25146
|
+
};
|
|
25147
|
+
for (const artifact of fetchResult.artifacts) {
|
|
25148
|
+
const hasChanges = artifact.statusChanged || artifact.progressChanged || artifact.linkedIssues.length > 0;
|
|
25149
|
+
if (hasChanges) {
|
|
25150
|
+
const updates = {
|
|
25151
|
+
status: artifact.proposedMarvinStatus,
|
|
25152
|
+
lastJiraSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
25153
|
+
jiraLinkedIssues: artifact.linkedIssues
|
|
25154
|
+
};
|
|
25155
|
+
if (artifact.proposedProgress !== void 0) {
|
|
25156
|
+
updates.progress = artifact.proposedProgress;
|
|
25157
|
+
}
|
|
25158
|
+
store.update(artifact.id, updates);
|
|
25159
|
+
if (artifact.type === "task") {
|
|
25160
|
+
propagateProgressFromTask(store, artifact.id);
|
|
25161
|
+
} else if (artifact.type === "action") {
|
|
25162
|
+
propagateProgressToAction(store, artifact.id);
|
|
25163
|
+
}
|
|
25164
|
+
result.updated.push({
|
|
25165
|
+
id: artifact.id,
|
|
25166
|
+
jiraKey: artifact.jiraKey,
|
|
25167
|
+
oldStatus: artifact.currentMarvinStatus,
|
|
25168
|
+
newStatus: artifact.proposedMarvinStatus,
|
|
25169
|
+
linkedIssues: artifact.linkedIssues
|
|
25170
|
+
});
|
|
25171
|
+
} else {
|
|
25172
|
+
store.update(artifact.id, {
|
|
25173
|
+
lastJiraSyncAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
25174
|
+
});
|
|
25175
|
+
result.unchanged++;
|
|
25176
|
+
}
|
|
25177
|
+
}
|
|
25178
|
+
return result;
|
|
25179
|
+
}
|
|
25180
|
+
|
|
25181
|
+
// src/skills/builtin/jira/daily.ts
|
|
25182
|
+
var BLOCKER_PATTERNS = [
|
|
25183
|
+
/\bblocked\b/i,
|
|
25184
|
+
/\bblocking\b/i,
|
|
25185
|
+
/\bwaiting\s+for\b/i,
|
|
25186
|
+
/\bon\s+hold\b/i,
|
|
25187
|
+
/\bcan'?t\s+proceed\b/i,
|
|
25188
|
+
/\bdepends?\s+on\b/i,
|
|
25189
|
+
/\bstuck\b/i,
|
|
25190
|
+
/\bneed[s]?\s+(to\s+wait|approval|input|clarification)\b/i
|
|
25191
|
+
];
|
|
25192
|
+
var DECISION_PATTERNS = [
|
|
25193
|
+
/\bdecided\b/i,
|
|
25194
|
+
/\bagreed\b/i,
|
|
25195
|
+
/\bapproved?\b/i,
|
|
25196
|
+
/\blet'?s?\s+go\s+with\b/i,
|
|
25197
|
+
/\bwe('ll|\s+will)\s+(use|go|proceed|adopt)\b/i,
|
|
25198
|
+
/\bsigned\s+off\b/i,
|
|
25199
|
+
/\bconfirmed\b/i
|
|
25200
|
+
];
|
|
25201
|
+
var QUESTION_PATTERNS = [
|
|
25202
|
+
/\?/,
|
|
25203
|
+
/\bdoes\s+anyone\s+know\b/i,
|
|
25204
|
+
/\bhow\s+should\s+we\b/i,
|
|
25205
|
+
/\bneed\s+clarification\b/i,
|
|
25206
|
+
/\bwhat('s|\s+is)\s+the\s+(plan|approach|status)\b/i,
|
|
25207
|
+
/\bshould\s+we\b/i,
|
|
25208
|
+
/\bany\s+(idea|thought|suggestion)s?\b/i,
|
|
25209
|
+
/\bopen\s+question\b/i
|
|
25210
|
+
];
|
|
25211
|
+
var RESOLUTION_PATTERNS = [
|
|
25212
|
+
/\bfixed\b/i,
|
|
25213
|
+
/\bresolved\b/i,
|
|
25214
|
+
/\bmerged\b/i,
|
|
25215
|
+
/\bdeployed\b/i,
|
|
25216
|
+
/\bcompleted?\b/i,
|
|
25217
|
+
/\bshipped\b/i,
|
|
25218
|
+
/\bimplemented\b/i,
|
|
25219
|
+
/\bclosed\b/i
|
|
25220
|
+
];
|
|
25221
|
+
function detectCommentSignals(text) {
|
|
25222
|
+
const signals = [];
|
|
25223
|
+
const lines = text.split("\n");
|
|
25224
|
+
for (const line of lines) {
|
|
25225
|
+
const trimmed = line.trim();
|
|
25226
|
+
if (!trimmed) continue;
|
|
25227
|
+
for (const pattern of BLOCKER_PATTERNS) {
|
|
25228
|
+
if (pattern.test(trimmed)) {
|
|
25229
|
+
signals.push({ type: "blocker", snippet: truncate(trimmed, 120) });
|
|
25230
|
+
break;
|
|
25231
|
+
}
|
|
25232
|
+
}
|
|
25233
|
+
for (const pattern of DECISION_PATTERNS) {
|
|
25234
|
+
if (pattern.test(trimmed)) {
|
|
25235
|
+
signals.push({ type: "decision", snippet: truncate(trimmed, 120) });
|
|
25236
|
+
break;
|
|
25237
|
+
}
|
|
25238
|
+
}
|
|
25239
|
+
for (const pattern of QUESTION_PATTERNS) {
|
|
25240
|
+
if (pattern.test(trimmed)) {
|
|
25241
|
+
signals.push({ type: "question", snippet: truncate(trimmed, 120) });
|
|
25242
|
+
break;
|
|
25243
|
+
}
|
|
25244
|
+
}
|
|
25245
|
+
for (const pattern of RESOLUTION_PATTERNS) {
|
|
25246
|
+
if (pattern.test(trimmed)) {
|
|
25247
|
+
signals.push({ type: "resolution", snippet: truncate(trimmed, 120) });
|
|
25248
|
+
break;
|
|
25249
|
+
}
|
|
25250
|
+
}
|
|
25251
|
+
}
|
|
25252
|
+
const seen = /* @__PURE__ */ new Set();
|
|
25253
|
+
return signals.filter((s) => {
|
|
25254
|
+
if (seen.has(s.type)) return false;
|
|
25255
|
+
seen.add(s.type);
|
|
25256
|
+
return true;
|
|
25257
|
+
});
|
|
25258
|
+
}
|
|
25259
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
25260
|
+
"a",
|
|
25261
|
+
"an",
|
|
25262
|
+
"the",
|
|
25263
|
+
"and",
|
|
25264
|
+
"or",
|
|
25265
|
+
"but",
|
|
25266
|
+
"in",
|
|
25267
|
+
"on",
|
|
25268
|
+
"at",
|
|
25269
|
+
"to",
|
|
25270
|
+
"for",
|
|
25271
|
+
"of",
|
|
25272
|
+
"with",
|
|
25273
|
+
"by",
|
|
25274
|
+
"from",
|
|
25275
|
+
"is",
|
|
25276
|
+
"are",
|
|
25277
|
+
"was",
|
|
25278
|
+
"were",
|
|
25279
|
+
"be",
|
|
25280
|
+
"been",
|
|
25281
|
+
"this",
|
|
25282
|
+
"that",
|
|
25283
|
+
"it",
|
|
25284
|
+
"its",
|
|
25285
|
+
"as",
|
|
25286
|
+
"not",
|
|
25287
|
+
"no",
|
|
25288
|
+
"if",
|
|
25289
|
+
"do",
|
|
25290
|
+
"does",
|
|
25291
|
+
"new",
|
|
25292
|
+
"via",
|
|
25293
|
+
"use",
|
|
25294
|
+
"using",
|
|
25295
|
+
"based",
|
|
25296
|
+
"into",
|
|
25297
|
+
"e.g",
|
|
25298
|
+
"etc"
|
|
25299
|
+
]);
|
|
25300
|
+
function tokenize(text) {
|
|
25301
|
+
return new Set(
|
|
25302
|
+
text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/[\s-]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w))
|
|
25303
|
+
);
|
|
25304
|
+
}
|
|
25305
|
+
function computeTitleSimilarity(jiraSummary, artifactTitle) {
|
|
25306
|
+
const jiraTokens = tokenize(jiraSummary);
|
|
25307
|
+
const artifactTokens = tokenize(artifactTitle);
|
|
25308
|
+
if (jiraTokens.size === 0 || artifactTokens.size === 0) {
|
|
25309
|
+
return { score: 0, sharedTerms: [] };
|
|
25310
|
+
}
|
|
25311
|
+
const shared = [];
|
|
25312
|
+
for (const token of jiraTokens) {
|
|
25313
|
+
if (artifactTokens.has(token)) {
|
|
25314
|
+
shared.push(token);
|
|
25315
|
+
}
|
|
25316
|
+
}
|
|
25317
|
+
const union2 = /* @__PURE__ */ new Set([...jiraTokens, ...artifactTokens]);
|
|
25318
|
+
const score = shared.length / union2.size;
|
|
25319
|
+
return { score, sharedTerms: shared };
|
|
25320
|
+
}
|
|
25321
|
+
var LINK_SUGGESTION_THRESHOLD = 0.15;
|
|
25322
|
+
var MAX_LINK_SUGGESTIONS = 3;
|
|
25323
|
+
function findLinkSuggestions(jiraSummary, allDocs) {
|
|
25324
|
+
const suggestions = [];
|
|
25325
|
+
for (const doc of allDocs) {
|
|
25326
|
+
const fm = doc.frontmatter;
|
|
25327
|
+
if (fm.jiraKey) continue;
|
|
25328
|
+
const { score, sharedTerms } = computeTitleSimilarity(
|
|
25329
|
+
jiraSummary,
|
|
25330
|
+
fm.title
|
|
25331
|
+
);
|
|
25332
|
+
if (score >= LINK_SUGGESTION_THRESHOLD && sharedTerms.length >= 2) {
|
|
25333
|
+
suggestions.push({
|
|
25334
|
+
artifactId: fm.id,
|
|
25335
|
+
artifactType: fm.type,
|
|
25336
|
+
artifactTitle: fm.title,
|
|
25337
|
+
score,
|
|
25338
|
+
sharedTerms
|
|
25339
|
+
});
|
|
25340
|
+
}
|
|
25341
|
+
}
|
|
25342
|
+
return suggestions.sort((a, b) => b.score - a.score).slice(0, MAX_LINK_SUGGESTIONS);
|
|
25343
|
+
}
|
|
25344
|
+
function extractCommentText(body) {
|
|
25345
|
+
if (typeof body === "string") return body;
|
|
25346
|
+
if (!body || typeof body !== "object") return "";
|
|
25347
|
+
const parts = [];
|
|
25348
|
+
function walk(node) {
|
|
25349
|
+
if (!node || typeof node !== "object") return;
|
|
25350
|
+
const n = node;
|
|
25351
|
+
if (n.type === "text" && typeof n.text === "string") {
|
|
25352
|
+
parts.push(n.text);
|
|
25353
|
+
}
|
|
25354
|
+
if (Array.isArray(n.content)) {
|
|
25355
|
+
for (const child of n.content) walk(child);
|
|
25356
|
+
}
|
|
25357
|
+
}
|
|
25358
|
+
walk(body);
|
|
25359
|
+
return parts.join(" ");
|
|
25360
|
+
}
|
|
25361
|
+
function truncate(text, maxLen = 200) {
|
|
25362
|
+
if (text.length <= maxLen) return text;
|
|
25363
|
+
return text.slice(0, maxLen) + "\u2026";
|
|
25364
|
+
}
|
|
25365
|
+
function isWithinRange(timestamp, range) {
|
|
25366
|
+
const date5 = timestamp.slice(0, 10);
|
|
25367
|
+
return date5 >= range.from && date5 <= range.to;
|
|
25368
|
+
}
|
|
25369
|
+
function isConfluenceUrl(url2) {
|
|
25370
|
+
return /atlassian\.net\/wiki\//i.test(url2) || /\/confluence\//i.test(url2);
|
|
25371
|
+
}
|
|
25372
|
+
var DONE_STATUSES15 = /* @__PURE__ */ new Set(["done", "closed", "resolved", "obsolete", "wont do"]);
|
|
25373
|
+
async function fetchJiraDaily(store, client, host, projectKey, dateRange, statusMap) {
|
|
25374
|
+
const summary = {
|
|
25375
|
+
dateRange,
|
|
25376
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
25377
|
+
projectKey,
|
|
25378
|
+
issues: [],
|
|
25379
|
+
proposedActions: [],
|
|
25380
|
+
errors: []
|
|
25381
|
+
};
|
|
25382
|
+
const jql = `project = ${projectKey} AND updated >= "${dateRange.from}" AND updated <= "${dateRange.to} 23:59" ORDER BY updated DESC`;
|
|
25383
|
+
let searchResult;
|
|
25384
|
+
try {
|
|
25385
|
+
searchResult = await client.searchIssuesV3(
|
|
25386
|
+
jql,
|
|
25387
|
+
["summary", "status", "issuetype", "priority", "assignee", "labels"],
|
|
25388
|
+
100
|
|
25389
|
+
);
|
|
25390
|
+
} catch (err) {
|
|
25391
|
+
summary.errors.push(
|
|
25392
|
+
`Search failed: ${err instanceof Error ? err.message : String(err)}`
|
|
25393
|
+
);
|
|
25394
|
+
return summary;
|
|
25395
|
+
}
|
|
25396
|
+
const allDocs = [
|
|
25397
|
+
...store.list({ type: "action" }),
|
|
25398
|
+
...store.list({ type: "task" }),
|
|
25399
|
+
...store.list({ type: "decision" }),
|
|
25400
|
+
...store.list({ type: "question" })
|
|
25401
|
+
];
|
|
25402
|
+
const otherTypes = store.registeredTypes.filter(
|
|
25403
|
+
(t) => !["action", "task", "decision", "question"].includes(t)
|
|
25404
|
+
);
|
|
25405
|
+
for (const t of otherTypes) {
|
|
25406
|
+
allDocs.push(...store.list({ type: t }));
|
|
25407
|
+
}
|
|
25408
|
+
const jiraKeyToArtifacts = /* @__PURE__ */ new Map();
|
|
25409
|
+
for (const doc of allDocs) {
|
|
25410
|
+
const jk = doc.frontmatter.jiraKey;
|
|
25411
|
+
if (jk) {
|
|
25412
|
+
const list = jiraKeyToArtifacts.get(jk) ?? [];
|
|
25413
|
+
list.push(doc);
|
|
25414
|
+
jiraKeyToArtifacts.set(jk, list);
|
|
25415
|
+
}
|
|
25416
|
+
}
|
|
25417
|
+
const BATCH_SIZE = 5;
|
|
25418
|
+
const issues = searchResult.issues;
|
|
25419
|
+
for (let i = 0; i < issues.length; i += BATCH_SIZE) {
|
|
25420
|
+
const batch = issues.slice(i, i + BATCH_SIZE);
|
|
25421
|
+
const results = await Promise.allSettled(
|
|
25422
|
+
batch.map(
|
|
25423
|
+
(issue2) => processIssue(issue2, client, host, dateRange, jiraKeyToArtifacts, allDocs, statusMap)
|
|
25424
|
+
)
|
|
25425
|
+
);
|
|
25426
|
+
for (let j = 0; j < results.length; j++) {
|
|
25427
|
+
const r = results[j];
|
|
25428
|
+
if (r.status === "fulfilled") {
|
|
25429
|
+
summary.issues.push(r.value);
|
|
25430
|
+
} else {
|
|
25431
|
+
summary.errors.push(
|
|
25432
|
+
`${batch[j].key}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`
|
|
25433
|
+
);
|
|
25434
|
+
}
|
|
25435
|
+
}
|
|
25436
|
+
}
|
|
25437
|
+
summary.proposedActions = generateProposedActions(summary.issues);
|
|
25438
|
+
return summary;
|
|
25439
|
+
}
|
|
25440
|
+
async function processIssue(issue2, client, host, dateRange, jiraKeyToArtifacts, allDocs, statusMap) {
|
|
25441
|
+
const [changelogResult, commentsResult, remoteLinksResult, issueWithLinks] = await Promise.all([
|
|
25442
|
+
client.getChangelog(issue2.key).catch(() => []),
|
|
25443
|
+
client.getComments(issue2.key).catch(() => []),
|
|
25444
|
+
client.getRemoteLinks(issue2.key).catch(() => []),
|
|
25445
|
+
client.getIssueWithLinks(issue2.key).catch(() => null)
|
|
25446
|
+
]);
|
|
25447
|
+
const changes = [];
|
|
25448
|
+
for (const entry of changelogResult) {
|
|
25449
|
+
if (!isWithinRange(entry.created, dateRange)) continue;
|
|
25450
|
+
for (const item of entry.items) {
|
|
25451
|
+
changes.push({
|
|
25452
|
+
field: item.field,
|
|
25453
|
+
from: item.fromString,
|
|
25454
|
+
to: item.toString,
|
|
25455
|
+
author: entry.author.displayName,
|
|
25456
|
+
timestamp: entry.created
|
|
25457
|
+
});
|
|
25458
|
+
}
|
|
25459
|
+
}
|
|
25460
|
+
const comments = [];
|
|
25461
|
+
for (const comment of commentsResult) {
|
|
25462
|
+
if (!isWithinRange(comment.created, dateRange) && !isWithinRange(comment.updated, dateRange)) {
|
|
25463
|
+
continue;
|
|
25464
|
+
}
|
|
25465
|
+
const fullText = extractCommentText(comment.body);
|
|
25466
|
+
const signals = detectCommentSignals(fullText);
|
|
25467
|
+
comments.push({
|
|
25468
|
+
author: comment.author.displayName,
|
|
25469
|
+
created: comment.created,
|
|
25470
|
+
bodyPreview: truncate(fullText),
|
|
25471
|
+
signals
|
|
25472
|
+
});
|
|
25473
|
+
}
|
|
25474
|
+
const confluenceLinks = [];
|
|
25475
|
+
for (const rl of remoteLinksResult) {
|
|
25476
|
+
if (isConfluenceUrl(rl.object.url)) {
|
|
25477
|
+
confluenceLinks.push({
|
|
25478
|
+
url: rl.object.url,
|
|
25479
|
+
title: rl.object.title
|
|
25480
|
+
});
|
|
25481
|
+
}
|
|
25482
|
+
}
|
|
25483
|
+
const linkedIssues = [];
|
|
25484
|
+
if (issueWithLinks) {
|
|
25485
|
+
if (issueWithLinks.fields.subtasks) {
|
|
25486
|
+
for (const sub of issueWithLinks.fields.subtasks) {
|
|
25487
|
+
linkedIssues.push({
|
|
25488
|
+
key: sub.key,
|
|
25489
|
+
summary: sub.fields.summary,
|
|
25490
|
+
status: sub.fields.status.name,
|
|
25491
|
+
relationship: "subtask",
|
|
25492
|
+
isDone: DONE_STATUSES15.has(sub.fields.status.name.toLowerCase())
|
|
25493
|
+
});
|
|
25494
|
+
}
|
|
25495
|
+
}
|
|
25496
|
+
if (issueWithLinks.fields.issuelinks) {
|
|
25497
|
+
for (const link of issueWithLinks.fields.issuelinks) {
|
|
25498
|
+
if (link.outwardIssue) {
|
|
25499
|
+
linkedIssues.push({
|
|
25500
|
+
key: link.outwardIssue.key,
|
|
25501
|
+
summary: link.outwardIssue.fields.summary,
|
|
25502
|
+
status: link.outwardIssue.fields.status.name,
|
|
25503
|
+
relationship: link.type.outward,
|
|
25504
|
+
isDone: DONE_STATUSES15.has(link.outwardIssue.fields.status.name.toLowerCase())
|
|
25505
|
+
});
|
|
25506
|
+
}
|
|
25507
|
+
if (link.inwardIssue) {
|
|
25508
|
+
linkedIssues.push({
|
|
25509
|
+
key: link.inwardIssue.key,
|
|
25510
|
+
summary: link.inwardIssue.fields.summary,
|
|
25511
|
+
status: link.inwardIssue.fields.status.name,
|
|
25512
|
+
relationship: link.type.inward,
|
|
25513
|
+
isDone: DONE_STATUSES15.has(link.inwardIssue.fields.status.name.toLowerCase())
|
|
25514
|
+
});
|
|
25515
|
+
}
|
|
25516
|
+
}
|
|
25517
|
+
}
|
|
25518
|
+
}
|
|
25519
|
+
const marvinArtifacts = [];
|
|
25520
|
+
const artifacts = jiraKeyToArtifacts.get(issue2.key) ?? [];
|
|
25521
|
+
for (const doc of artifacts) {
|
|
25522
|
+
const fm = doc.frontmatter;
|
|
25523
|
+
const artifactType = fm.type;
|
|
25524
|
+
let proposedStatus = null;
|
|
25525
|
+
if (artifactType === "action" || artifactType === "task") {
|
|
25526
|
+
const jiraStatus = issue2.fields.status?.name;
|
|
25527
|
+
if (jiraStatus) {
|
|
25528
|
+
proposedStatus = artifactType === "task" ? mapJiraStatusForTask(jiraStatus, statusMap?.task) : mapJiraStatusForAction(jiraStatus, statusMap?.action);
|
|
25529
|
+
}
|
|
25530
|
+
}
|
|
25531
|
+
marvinArtifacts.push({
|
|
25532
|
+
id: fm.id,
|
|
25533
|
+
type: artifactType,
|
|
25534
|
+
title: fm.title,
|
|
25535
|
+
currentStatus: fm.status,
|
|
25536
|
+
proposedStatus,
|
|
25537
|
+
statusDrift: proposedStatus !== null && proposedStatus !== fm.status
|
|
25538
|
+
});
|
|
25539
|
+
}
|
|
25540
|
+
const linkSuggestions = marvinArtifacts.length === 0 ? findLinkSuggestions(issue2.fields.summary, allDocs) : [];
|
|
25541
|
+
return {
|
|
25542
|
+
key: issue2.key,
|
|
25543
|
+
summary: issue2.fields.summary,
|
|
25544
|
+
currentStatus: issue2.fields.status?.name ?? "Unknown",
|
|
25545
|
+
issueType: issue2.fields.issuetype?.name ?? "Unknown",
|
|
25546
|
+
assignee: issue2.fields.assignee?.displayName ?? null,
|
|
25547
|
+
changes,
|
|
25548
|
+
comments,
|
|
25549
|
+
linkedIssues,
|
|
25550
|
+
confluenceLinks,
|
|
25551
|
+
marvinArtifacts,
|
|
25552
|
+
linkSuggestions
|
|
25553
|
+
};
|
|
25554
|
+
}
|
|
25555
|
+
function generateProposedActions(issues) {
|
|
25556
|
+
const actions = [];
|
|
25557
|
+
for (const issue2 of issues) {
|
|
25558
|
+
for (const artifact of issue2.marvinArtifacts) {
|
|
25559
|
+
if (artifact.statusDrift && artifact.proposedStatus) {
|
|
25560
|
+
actions.push({
|
|
25561
|
+
type: "status-update",
|
|
25562
|
+
description: `Update ${artifact.id} (${artifact.type}) status: ${artifact.currentStatus} \u2192 ${artifact.proposedStatus} (Jira ${issue2.key} is "${issue2.currentStatus}")`,
|
|
25563
|
+
artifactId: artifact.id,
|
|
25564
|
+
jiraKey: issue2.key
|
|
25565
|
+
});
|
|
25566
|
+
}
|
|
25567
|
+
}
|
|
25568
|
+
if (issue2.marvinArtifacts.length === 0 && (issue2.changes.length > 0 || issue2.comments.length > 0)) {
|
|
25569
|
+
actions.push({
|
|
25570
|
+
type: "unlinked-issue",
|
|
25571
|
+
description: `${issue2.key} ("${issue2.summary}") has activity but no Marvin artifact \u2014 consider linking or creating one`,
|
|
25572
|
+
jiraKey: issue2.key
|
|
25573
|
+
});
|
|
25574
|
+
}
|
|
25575
|
+
for (const suggestion of issue2.linkSuggestions) {
|
|
25576
|
+
actions.push({
|
|
25577
|
+
type: "link-suggestion",
|
|
25578
|
+
description: `${issue2.key} ("${issue2.summary}") may match ${suggestion.artifactId} ("${suggestion.artifactTitle}") \u2014 shared terms: ${suggestion.sharedTerms.join(", ")} (${Math.round(suggestion.score * 100)}% similarity)`,
|
|
25579
|
+
artifactId: suggestion.artifactId,
|
|
25580
|
+
jiraKey: issue2.key
|
|
25581
|
+
});
|
|
25582
|
+
}
|
|
25583
|
+
for (const comment of issue2.comments) {
|
|
25584
|
+
for (const signal of comment.signals) {
|
|
25585
|
+
if (signal.type === "blocker") {
|
|
25586
|
+
actions.push({
|
|
25587
|
+
type: "blocker-detected",
|
|
25588
|
+
description: `Blocker in ${issue2.key} comment by ${comment.author}: "${signal.snippet}"`,
|
|
25589
|
+
jiraKey: issue2.key
|
|
25590
|
+
});
|
|
25591
|
+
}
|
|
25592
|
+
if (signal.type === "decision") {
|
|
25593
|
+
actions.push({
|
|
25594
|
+
type: "decision-candidate",
|
|
25595
|
+
description: `Possible decision in ${issue2.key} comment by ${comment.author}: "${signal.snippet}" \u2014 consider creating a decision artifact`,
|
|
25596
|
+
jiraKey: issue2.key
|
|
25597
|
+
});
|
|
25598
|
+
}
|
|
25599
|
+
if (signal.type === "question") {
|
|
25600
|
+
const linkedQuestion = issue2.marvinArtifacts.find(
|
|
25601
|
+
(a) => a.type === "question" && a.currentStatus !== "answered"
|
|
25602
|
+
);
|
|
25603
|
+
if (linkedQuestion) {
|
|
25604
|
+
actions.push({
|
|
25605
|
+
type: "question-candidate",
|
|
25606
|
+
description: `Question in ${issue2.key} comment by ${comment.author} \u2014 may relate to ${linkedQuestion.id} ("${linkedQuestion.title}"): "${signal.snippet}"`,
|
|
25607
|
+
artifactId: linkedQuestion.id,
|
|
25608
|
+
jiraKey: issue2.key
|
|
25609
|
+
});
|
|
25610
|
+
} else {
|
|
25611
|
+
actions.push({
|
|
25612
|
+
type: "question-candidate",
|
|
25613
|
+
description: `Question in ${issue2.key} comment by ${comment.author}: "${signal.snippet}" \u2014 consider creating a question artifact`,
|
|
25614
|
+
jiraKey: issue2.key
|
|
25615
|
+
});
|
|
25616
|
+
}
|
|
25617
|
+
}
|
|
25618
|
+
if (signal.type === "resolution") {
|
|
25619
|
+
const linkedQuestion = issue2.marvinArtifacts.find(
|
|
25620
|
+
(a) => a.type === "question" && a.currentStatus !== "answered"
|
|
25621
|
+
);
|
|
25622
|
+
if (linkedQuestion) {
|
|
25623
|
+
actions.push({
|
|
25624
|
+
type: "resolution-detected",
|
|
25625
|
+
description: `Resolution in ${issue2.key} by ${comment.author} may answer ${linkedQuestion.id} ("${linkedQuestion.title}"): "${signal.snippet}"`,
|
|
25626
|
+
artifactId: linkedQuestion.id,
|
|
25627
|
+
jiraKey: issue2.key
|
|
25628
|
+
});
|
|
25629
|
+
}
|
|
25630
|
+
}
|
|
25631
|
+
}
|
|
25632
|
+
}
|
|
25633
|
+
for (const cl of issue2.confluenceLinks) {
|
|
25634
|
+
actions.push({
|
|
25635
|
+
type: "confluence-review",
|
|
25636
|
+
description: `Confluence page "${cl.title}" linked from ${issue2.key} \u2014 review for relevant updates`,
|
|
25637
|
+
jiraKey: issue2.key
|
|
25638
|
+
});
|
|
25639
|
+
}
|
|
25640
|
+
}
|
|
25641
|
+
return actions;
|
|
24529
25642
|
}
|
|
24530
25643
|
|
|
24531
25644
|
// src/skills/builtin/jira/tools.ts
|
|
@@ -24569,6 +25682,7 @@ function findByJiraKey(store, jiraKey) {
|
|
|
24569
25682
|
function createJiraTools(store, projectConfig) {
|
|
24570
25683
|
const jiraUserConfig = loadUserConfig().jira;
|
|
24571
25684
|
const defaultProjectKey = projectConfig?.jira?.projectKey;
|
|
25685
|
+
const statusMap = projectConfig?.jira?.statusMap;
|
|
24572
25686
|
return [
|
|
24573
25687
|
// --- Local read tools ---
|
|
24574
25688
|
tool20(
|
|
@@ -24729,9 +25843,9 @@ function createJiraTools(store, projectConfig) {
|
|
|
24729
25843
|
// --- Local → Jira tools ---
|
|
24730
25844
|
tool20(
|
|
24731
25845
|
"push_artifact_to_jira",
|
|
24732
|
-
"Create a Jira issue from
|
|
25846
|
+
"Create a Jira issue from a Marvin artifact. For actions/tasks, links directly via jiraKey on the artifact. For other types, creates a JI-xxx tracking document.",
|
|
24733
25847
|
{
|
|
24734
|
-
artifactId: external_exports.string().describe("Marvin artifact ID (e.g. 'D-001', '
|
|
25848
|
+
artifactId: external_exports.string().describe("Marvin artifact ID (e.g. 'D-001', 'A-003', 'T-002')"),
|
|
24735
25849
|
projectKey: external_exports.string().optional().describe("Jira project key (e.g. 'PROJ'). Falls back to jira.projectKey from .marvin/config.yaml if not provided."),
|
|
24736
25850
|
issueType: external_exports.enum(["Story", "Task", "Bug", "Epic"]).optional().describe("Jira issue type (default: 'Task')")
|
|
24737
25851
|
},
|
|
@@ -24772,6 +25886,24 @@ function createJiraTools(store, projectConfig) {
|
|
|
24772
25886
|
description,
|
|
24773
25887
|
issuetype: { name: args.issueType ?? "Task" }
|
|
24774
25888
|
});
|
|
25889
|
+
const isDirectLink = artifact.frontmatter.type === "action" || artifact.frontmatter.type === "task";
|
|
25890
|
+
if (isDirectLink) {
|
|
25891
|
+
const existingTags = artifact.frontmatter.tags ?? [];
|
|
25892
|
+
store.update(args.artifactId, {
|
|
25893
|
+
jiraKey: jiraResult.key,
|
|
25894
|
+
jiraUrl: `https://${jira.host}/browse/${jiraResult.key}`,
|
|
25895
|
+
lastJiraSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
25896
|
+
tags: [...existingTags.filter((t) => !t.startsWith("jira:")), `jira:${jiraResult.key}`]
|
|
25897
|
+
});
|
|
25898
|
+
return {
|
|
25899
|
+
content: [
|
|
25900
|
+
{
|
|
25901
|
+
type: "text",
|
|
25902
|
+
text: `Created Jira ${jiraResult.key} from ${args.artifactId}. Linked directly on the artifact.`
|
|
25903
|
+
}
|
|
25904
|
+
]
|
|
25905
|
+
};
|
|
25906
|
+
}
|
|
24775
25907
|
const jiDoc = store.create(
|
|
24776
25908
|
JIRA_TYPE,
|
|
24777
25909
|
{
|
|
@@ -24840,118 +25972,483 @@ function createJiraTools(store, projectConfig) {
|
|
|
24840
25972
|
};
|
|
24841
25973
|
}
|
|
24842
25974
|
),
|
|
24843
|
-
// --- Local link tool ---
|
|
25975
|
+
// --- Local link tool ---
|
|
25976
|
+
tool20(
|
|
25977
|
+
"link_artifact_to_jira",
|
|
25978
|
+
"Add a Marvin artifact ID to a JI-xxx document's linkedArtifacts field",
|
|
25979
|
+
{
|
|
25980
|
+
jiraIssueId: external_exports.string().describe("Local JI-xxx ID"),
|
|
25981
|
+
artifactId: external_exports.string().describe("Marvin artifact ID to link (e.g. 'D-001', 'F-003')")
|
|
25982
|
+
},
|
|
25983
|
+
async (args) => {
|
|
25984
|
+
const doc = store.get(args.jiraIssueId);
|
|
25985
|
+
if (!doc || doc.frontmatter.type !== JIRA_TYPE) {
|
|
25986
|
+
return {
|
|
25987
|
+
content: [
|
|
25988
|
+
{
|
|
25989
|
+
type: "text",
|
|
25990
|
+
text: `Jira issue ${args.jiraIssueId} not found locally`
|
|
25991
|
+
}
|
|
25992
|
+
],
|
|
25993
|
+
isError: true
|
|
25994
|
+
};
|
|
25995
|
+
}
|
|
25996
|
+
const artifact = store.get(args.artifactId);
|
|
25997
|
+
if (!artifact) {
|
|
25998
|
+
return {
|
|
25999
|
+
content: [
|
|
26000
|
+
{ type: "text", text: `Artifact ${args.artifactId} not found` }
|
|
26001
|
+
],
|
|
26002
|
+
isError: true
|
|
26003
|
+
};
|
|
26004
|
+
}
|
|
26005
|
+
const linked = doc.frontmatter.linkedArtifacts ?? [];
|
|
26006
|
+
if (linked.includes(args.artifactId)) {
|
|
26007
|
+
return {
|
|
26008
|
+
content: [
|
|
26009
|
+
{
|
|
26010
|
+
type: "text",
|
|
26011
|
+
text: `${args.artifactId} is already linked to ${args.jiraIssueId}`
|
|
26012
|
+
}
|
|
26013
|
+
]
|
|
26014
|
+
};
|
|
26015
|
+
}
|
|
26016
|
+
store.update(args.jiraIssueId, {
|
|
26017
|
+
linkedArtifacts: [...linked, args.artifactId]
|
|
26018
|
+
});
|
|
26019
|
+
return {
|
|
26020
|
+
content: [
|
|
26021
|
+
{
|
|
26022
|
+
type: "text",
|
|
26023
|
+
text: `Linked ${args.artifactId} to ${args.jiraIssueId}`
|
|
26024
|
+
}
|
|
26025
|
+
]
|
|
26026
|
+
};
|
|
26027
|
+
}
|
|
26028
|
+
),
|
|
26029
|
+
// --- Direct Jira linking for actions/tasks ---
|
|
26030
|
+
tool20(
|
|
26031
|
+
"link_to_jira",
|
|
26032
|
+
"Link an existing Jira issue to a Marvin action or task (sets jiraKey directly on the artifact)",
|
|
26033
|
+
{
|
|
26034
|
+
artifactId: external_exports.string().describe("Marvin artifact ID (e.g. 'A-001', 'T-003')"),
|
|
26035
|
+
jiraKey: external_exports.string().describe("Jira issue key (e.g. 'PROJ-123')")
|
|
26036
|
+
},
|
|
26037
|
+
async (args) => {
|
|
26038
|
+
const jira = createJiraClient(jiraUserConfig);
|
|
26039
|
+
if (!jira) return jiraNotConfiguredError();
|
|
26040
|
+
const artifact = store.get(args.artifactId);
|
|
26041
|
+
if (!artifact) {
|
|
26042
|
+
return {
|
|
26043
|
+
content: [
|
|
26044
|
+
{ type: "text", text: `Artifact ${args.artifactId} not found` }
|
|
26045
|
+
],
|
|
26046
|
+
isError: true
|
|
26047
|
+
};
|
|
26048
|
+
}
|
|
26049
|
+
if (artifact.frontmatter.type !== "action" && artifact.frontmatter.type !== "task") {
|
|
26050
|
+
return {
|
|
26051
|
+
content: [
|
|
26052
|
+
{
|
|
26053
|
+
type: "text",
|
|
26054
|
+
text: `link_to_jira only supports action and task artifacts. ${args.artifactId} is type "${artifact.frontmatter.type}". Use link_artifact_to_jira for JI-xxx documents instead.`
|
|
26055
|
+
}
|
|
26056
|
+
],
|
|
26057
|
+
isError: true
|
|
26058
|
+
};
|
|
26059
|
+
}
|
|
26060
|
+
const issue2 = await jira.client.getIssue(args.jiraKey);
|
|
26061
|
+
const existingTags = artifact.frontmatter.tags ?? [];
|
|
26062
|
+
store.update(args.artifactId, {
|
|
26063
|
+
jiraKey: args.jiraKey,
|
|
26064
|
+
jiraUrl: `https://${jira.host}/browse/${args.jiraKey}`,
|
|
26065
|
+
lastJiraSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
26066
|
+
tags: [...existingTags.filter((t) => !t.startsWith("jira:")), `jira:${args.jiraKey}`]
|
|
26067
|
+
});
|
|
26068
|
+
return {
|
|
26069
|
+
content: [
|
|
26070
|
+
{
|
|
26071
|
+
type: "text",
|
|
26072
|
+
text: `Linked ${args.artifactId} to Jira ${args.jiraKey} ("${issue2.fields.summary}").`
|
|
26073
|
+
}
|
|
26074
|
+
]
|
|
26075
|
+
};
|
|
26076
|
+
}
|
|
26077
|
+
),
|
|
26078
|
+
// --- Jira status fetch (read-only) ---
|
|
24844
26079
|
tool20(
|
|
24845
|
-
"
|
|
24846
|
-
"
|
|
26080
|
+
"fetch_jira_status",
|
|
26081
|
+
"Fetch current Jira status for actions/tasks with jiraKey. Read-only \u2014 returns proposed changes for review. Use update_action/update_task to apply changes.",
|
|
24847
26082
|
{
|
|
24848
|
-
|
|
24849
|
-
artifactId: external_exports.string().describe("Marvin artifact ID to link (e.g. 'D-001', 'F-003')")
|
|
26083
|
+
artifactId: external_exports.string().optional().describe("Specific artifact ID to check, or omit to check all Jira-linked actions/tasks")
|
|
24850
26084
|
},
|
|
24851
26085
|
async (args) => {
|
|
24852
|
-
const
|
|
24853
|
-
if (!
|
|
26086
|
+
const jira = createJiraClient(jiraUserConfig);
|
|
26087
|
+
if (!jira) return jiraNotConfiguredError();
|
|
26088
|
+
const fetchResult = await fetchJiraStatus(
|
|
26089
|
+
store,
|
|
26090
|
+
jira.client,
|
|
26091
|
+
jira.host,
|
|
26092
|
+
args.artifactId,
|
|
26093
|
+
statusMap
|
|
26094
|
+
);
|
|
26095
|
+
const parts = [];
|
|
26096
|
+
if (fetchResult.artifacts.length > 0) {
|
|
26097
|
+
for (const a of fetchResult.artifacts) {
|
|
26098
|
+
const changes = [];
|
|
26099
|
+
if (a.statusChanged) {
|
|
26100
|
+
changes.push(`status: ${a.currentMarvinStatus} \u2192 ${a.proposedMarvinStatus}`);
|
|
26101
|
+
}
|
|
26102
|
+
if (a.progressChanged) {
|
|
26103
|
+
changes.push(`progress: ${a.currentProgress ?? 0}% \u2192 ${a.proposedProgress}%`);
|
|
26104
|
+
}
|
|
26105
|
+
const header = `${a.id} (${a.jiraKey}) \u2014 Jira: "${a.jiraSummary}" [${a.jiraStatus}]`;
|
|
26106
|
+
if (changes.length > 0) {
|
|
26107
|
+
parts.push(`${header}
|
|
26108
|
+
Proposed changes: ${changes.join(", ")}`);
|
|
26109
|
+
} else {
|
|
26110
|
+
parts.push(`${header}
|
|
26111
|
+
No status/progress changes.`);
|
|
26112
|
+
}
|
|
26113
|
+
if (a.linkedIssues.length > 0) {
|
|
26114
|
+
const done = a.linkedIssues.filter((l) => l.isDone).length;
|
|
26115
|
+
parts.push(` Linked issues (${done}/${a.linkedIssues.length} done):`);
|
|
26116
|
+
for (const li of a.linkedIssues) {
|
|
26117
|
+
const icon = li.isDone ? "\u2713" : "\u25CB";
|
|
26118
|
+
parts.push(` ${icon} ${li.key} ${li.summary} [${li.relationship}] \u2014 ${li.status}`);
|
|
26119
|
+
}
|
|
26120
|
+
}
|
|
26121
|
+
}
|
|
26122
|
+
parts.push("");
|
|
26123
|
+
parts.push("This is a read-only preview. Use update_action or update_task to apply the proposed status/progress changes.");
|
|
26124
|
+
}
|
|
26125
|
+
if (fetchResult.errors.length > 0) {
|
|
26126
|
+
parts.push("Errors:");
|
|
26127
|
+
for (const err of fetchResult.errors) {
|
|
26128
|
+
parts.push(` ${err}`);
|
|
26129
|
+
}
|
|
26130
|
+
}
|
|
26131
|
+
if (fetchResult.artifacts.length === 0 && fetchResult.errors.length === 0) {
|
|
26132
|
+
parts.push("No Jira-linked actions/tasks found.");
|
|
26133
|
+
}
|
|
26134
|
+
return {
|
|
26135
|
+
content: [{ type: "text", text: parts.join("\n") }],
|
|
26136
|
+
isError: fetchResult.errors.length > 0 && fetchResult.artifacts.length === 0
|
|
26137
|
+
};
|
|
26138
|
+
},
|
|
26139
|
+
{ annotations: { readOnlyHint: true } }
|
|
26140
|
+
),
|
|
26141
|
+
// --- Jira status discovery ---
|
|
26142
|
+
tool20(
|
|
26143
|
+
"fetch_jira_statuses",
|
|
26144
|
+
"Fetch all distinct issue statuses from a Jira project and show which are mapped vs unmapped to Marvin statuses. Helps configure jira.statusMap in .marvin/config.yaml.",
|
|
26145
|
+
{
|
|
26146
|
+
projectKey: external_exports.string().optional().describe("Jira project key (e.g. 'MCB1'). Falls back to jira.projectKey from config."),
|
|
26147
|
+
maxResults: external_exports.number().optional().describe("Max issues to scan (default 100)")
|
|
26148
|
+
},
|
|
26149
|
+
async (args) => {
|
|
26150
|
+
const resolvedProjectKey = args.projectKey ?? defaultProjectKey;
|
|
26151
|
+
if (!resolvedProjectKey) {
|
|
24854
26152
|
return {
|
|
24855
26153
|
content: [
|
|
24856
26154
|
{
|
|
24857
26155
|
type: "text",
|
|
24858
|
-
text:
|
|
26156
|
+
text: "No projectKey provided and no default configured."
|
|
24859
26157
|
}
|
|
24860
26158
|
],
|
|
24861
26159
|
isError: true
|
|
24862
26160
|
};
|
|
24863
26161
|
}
|
|
24864
|
-
const
|
|
24865
|
-
if (!
|
|
26162
|
+
const jira = createJiraClient(jiraUserConfig);
|
|
26163
|
+
if (!jira) return jiraNotConfiguredError();
|
|
26164
|
+
const host = jira.host;
|
|
26165
|
+
const auth = "Basic " + Buffer.from(
|
|
26166
|
+
`${jiraUserConfig?.email ?? process.env.JIRA_EMAIL}:${jiraUserConfig?.apiToken ?? process.env.JIRA_API_TOKEN}`
|
|
26167
|
+
).toString("base64");
|
|
26168
|
+
const params = new URLSearchParams({
|
|
26169
|
+
jql: `project = ${resolvedProjectKey}`,
|
|
26170
|
+
maxResults: String(args.maxResults ?? 100),
|
|
26171
|
+
fields: "status"
|
|
26172
|
+
});
|
|
26173
|
+
const resp = await fetch(`https://${host}/rest/api/3/search/jql?${params}`, {
|
|
26174
|
+
headers: { Authorization: auth, Accept: "application/json" }
|
|
26175
|
+
});
|
|
26176
|
+
if (!resp.ok) {
|
|
26177
|
+
const text = await resp.text().catch(() => "");
|
|
24866
26178
|
return {
|
|
24867
26179
|
content: [
|
|
24868
|
-
{
|
|
26180
|
+
{
|
|
26181
|
+
type: "text",
|
|
26182
|
+
text: `Jira API error ${resp.status}: ${text}`
|
|
26183
|
+
}
|
|
24869
26184
|
],
|
|
24870
26185
|
isError: true
|
|
24871
26186
|
};
|
|
24872
26187
|
}
|
|
24873
|
-
const
|
|
24874
|
-
|
|
26188
|
+
const data = await resp.json();
|
|
26189
|
+
const statusCounts = /* @__PURE__ */ new Map();
|
|
26190
|
+
for (const issue2 of data.issues) {
|
|
26191
|
+
const s = issue2.fields.status.name;
|
|
26192
|
+
statusCounts.set(s, (statusCounts.get(s) ?? 0) + 1);
|
|
26193
|
+
}
|
|
26194
|
+
const actionMap = statusMap?.action ?? DEFAULT_ACTION_STATUS_MAP;
|
|
26195
|
+
const taskMap = statusMap?.task ?? DEFAULT_TASK_STATUS_MAP;
|
|
26196
|
+
const actionLookup = /* @__PURE__ */ new Map();
|
|
26197
|
+
for (const [marvin, jiraStatuses] of Object.entries(actionMap)) {
|
|
26198
|
+
for (const js of jiraStatuses) actionLookup.set(js.toLowerCase(), marvin);
|
|
26199
|
+
}
|
|
26200
|
+
const taskLookup = /* @__PURE__ */ new Map();
|
|
26201
|
+
for (const [marvin, jiraStatuses] of Object.entries(taskMap)) {
|
|
26202
|
+
for (const js of jiraStatuses) taskLookup.set(js.toLowerCase(), marvin);
|
|
26203
|
+
}
|
|
26204
|
+
const parts = [
|
|
26205
|
+
`Found ${statusCounts.size} distinct statuses in ${resolvedProjectKey} (scanned ${data.issues.length} of ${data.total} issues):`,
|
|
26206
|
+
""
|
|
26207
|
+
];
|
|
26208
|
+
const sorted = [...statusCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
26209
|
+
const unmappedAction = [];
|
|
26210
|
+
const unmappedTask = [];
|
|
26211
|
+
for (const [status, count] of sorted) {
|
|
26212
|
+
const actionTarget = actionLookup.get(status.toLowerCase());
|
|
26213
|
+
const taskTarget = taskLookup.get(status.toLowerCase());
|
|
26214
|
+
const actionLabel = actionTarget ? `\u2192 ${actionTarget}` : "UNMAPPED (\u2192 open)";
|
|
26215
|
+
const taskLabel = taskTarget ? `\u2192 ${taskTarget}` : "UNMAPPED (\u2192 backlog)";
|
|
26216
|
+
parts.push(` ${status} (${count} issues)`);
|
|
26217
|
+
parts.push(` action: ${actionLabel}`);
|
|
26218
|
+
parts.push(` task: ${taskLabel}`);
|
|
26219
|
+
if (!actionTarget) unmappedAction.push(status);
|
|
26220
|
+
if (!taskTarget) unmappedTask.push(status);
|
|
26221
|
+
}
|
|
26222
|
+
if (unmappedAction.length > 0 || unmappedTask.length > 0) {
|
|
26223
|
+
parts.push("");
|
|
26224
|
+
parts.push("To fix unmapped statuses, add jira.statusMap to .marvin/config.yaml:");
|
|
26225
|
+
parts.push(" jira:");
|
|
26226
|
+
parts.push(" statusMap:");
|
|
26227
|
+
if (unmappedAction.length > 0) {
|
|
26228
|
+
parts.push(" action:");
|
|
26229
|
+
parts.push(` # Map these: ${unmappedAction.join(", ")}`);
|
|
26230
|
+
parts.push(" # <marvin-status>: [<jira-status>, ...]");
|
|
26231
|
+
}
|
|
26232
|
+
if (unmappedTask.length > 0) {
|
|
26233
|
+
parts.push(" task:");
|
|
26234
|
+
parts.push(` # Map these: ${unmappedTask.join(", ")}`);
|
|
26235
|
+
parts.push(" # <marvin-status>: [<jira-status>, ...]");
|
|
26236
|
+
}
|
|
26237
|
+
} else {
|
|
26238
|
+
parts.push("");
|
|
26239
|
+
parts.push("All statuses are mapped.");
|
|
26240
|
+
}
|
|
26241
|
+
const usingConfig = statusMap?.action || statusMap?.task;
|
|
26242
|
+
parts.push("");
|
|
26243
|
+
parts.push(usingConfig ? "Using status maps from .marvin/config.yaml." : "Using built-in default status maps (no jira.statusMap in config).");
|
|
26244
|
+
return {
|
|
26245
|
+
content: [{ type: "text", text: parts.join("\n") }]
|
|
26246
|
+
};
|
|
26247
|
+
},
|
|
26248
|
+
{ annotations: { readOnlyHint: true } }
|
|
26249
|
+
),
|
|
26250
|
+
// --- Jira daily summary ---
|
|
26251
|
+
tool20(
|
|
26252
|
+
"fetch_jira_daily",
|
|
26253
|
+
"Fetch a daily summary of Jira changes: status transitions, comments, linked Confluence pages, and cross-referenced Marvin artifacts. Read-only \u2014 returns proposed actions for review.",
|
|
26254
|
+
{
|
|
26255
|
+
from: external_exports.string().optional().describe("Start date (YYYY-MM-DD). Defaults to today."),
|
|
26256
|
+
to: external_exports.string().optional().describe("End date (YYYY-MM-DD). Defaults to same as 'from'."),
|
|
26257
|
+
projectKey: external_exports.string().optional().describe("Jira project key. Falls back to jira.projectKey from config.")
|
|
26258
|
+
},
|
|
26259
|
+
async (args) => {
|
|
26260
|
+
const resolvedProjectKey = args.projectKey ?? defaultProjectKey;
|
|
26261
|
+
if (!resolvedProjectKey) {
|
|
24875
26262
|
return {
|
|
24876
26263
|
content: [
|
|
24877
26264
|
{
|
|
24878
26265
|
type: "text",
|
|
24879
|
-
text:
|
|
26266
|
+
text: "No projectKey provided and no default configured."
|
|
24880
26267
|
}
|
|
24881
|
-
]
|
|
26268
|
+
],
|
|
26269
|
+
isError: true
|
|
24882
26270
|
};
|
|
24883
26271
|
}
|
|
24884
|
-
|
|
24885
|
-
|
|
24886
|
-
|
|
26272
|
+
const jira = createJiraClient(jiraUserConfig);
|
|
26273
|
+
if (!jira) return jiraNotConfiguredError();
|
|
26274
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
26275
|
+
const fromDate = args.from ?? today;
|
|
26276
|
+
const toDate = args.to ?? fromDate;
|
|
26277
|
+
const daily = await fetchJiraDaily(
|
|
26278
|
+
store,
|
|
26279
|
+
jira.client,
|
|
26280
|
+
jira.host,
|
|
26281
|
+
resolvedProjectKey,
|
|
26282
|
+
{ from: fromDate, to: toDate },
|
|
26283
|
+
statusMap
|
|
26284
|
+
);
|
|
24887
26285
|
return {
|
|
24888
|
-
content: [
|
|
24889
|
-
|
|
24890
|
-
type: "text",
|
|
24891
|
-
text: `Linked ${args.artifactId} to ${args.jiraIssueId}`
|
|
24892
|
-
}
|
|
24893
|
-
]
|
|
26286
|
+
content: [{ type: "text", text: formatDailySummary(daily) }],
|
|
26287
|
+
isError: daily.errors.length > 0 && daily.issues.length === 0
|
|
24894
26288
|
};
|
|
24895
|
-
}
|
|
26289
|
+
},
|
|
26290
|
+
{ annotations: { readOnlyHint: true } }
|
|
24896
26291
|
)
|
|
24897
26292
|
];
|
|
24898
26293
|
}
|
|
26294
|
+
function formatDailySummary(daily) {
|
|
26295
|
+
const parts = [];
|
|
26296
|
+
const rangeLabel = daily.dateRange.from === daily.dateRange.to ? daily.dateRange.from : `${daily.dateRange.from} to ${daily.dateRange.to}`;
|
|
26297
|
+
parts.push(`Jira Daily Summary \u2014 ${daily.projectKey} \u2014 ${rangeLabel}`);
|
|
26298
|
+
parts.push(`${daily.issues.length} issue(s) updated.
|
|
26299
|
+
`);
|
|
26300
|
+
const linked = daily.issues.filter((i) => i.marvinArtifacts.length > 0);
|
|
26301
|
+
const unlinked = daily.issues.filter((i) => i.marvinArtifacts.length === 0);
|
|
26302
|
+
if (linked.length > 0) {
|
|
26303
|
+
parts.push("## Linked Issues (with Marvin artifacts)\n");
|
|
26304
|
+
for (const issue2 of linked) {
|
|
26305
|
+
parts.push(formatIssueEntry(issue2));
|
|
26306
|
+
}
|
|
26307
|
+
}
|
|
26308
|
+
if (unlinked.length > 0) {
|
|
26309
|
+
parts.push("## Unlinked Issues (no Marvin artifact)\n");
|
|
26310
|
+
for (const issue2 of unlinked) {
|
|
26311
|
+
parts.push(formatIssueEntry(issue2));
|
|
26312
|
+
}
|
|
26313
|
+
}
|
|
26314
|
+
if (daily.proposedActions.length > 0) {
|
|
26315
|
+
parts.push("## Proposed Actions\n");
|
|
26316
|
+
for (const action of daily.proposedActions) {
|
|
26317
|
+
const icon = action.type === "status-update" ? "\u21BB" : action.type === "unlinked-issue" ? "+" : action.type === "link-suggestion" ? "\u{1F517}" : action.type === "question-candidate" ? "?" : action.type === "decision-candidate" ? "\u2696" : action.type === "blocker-detected" ? "\u{1F6AB}" : action.type === "resolution-detected" ? "\u2713" : "\u{1F4C4}";
|
|
26318
|
+
parts.push(` ${icon} ${action.description}`);
|
|
26319
|
+
}
|
|
26320
|
+
parts.push("");
|
|
26321
|
+
parts.push("These are suggestions. Use update_action, update_task, or other tools to apply changes.");
|
|
26322
|
+
}
|
|
26323
|
+
if (daily.errors.length > 0) {
|
|
26324
|
+
parts.push("\n## Errors\n");
|
|
26325
|
+
for (const err of daily.errors) {
|
|
26326
|
+
parts.push(` ${err}`);
|
|
26327
|
+
}
|
|
26328
|
+
}
|
|
26329
|
+
return parts.join("\n");
|
|
26330
|
+
}
|
|
26331
|
+
function formatIssueEntry(issue2) {
|
|
26332
|
+
const lines = [];
|
|
26333
|
+
const artifacts = issue2.marvinArtifacts.map((a) => a.id).join(", ");
|
|
26334
|
+
const artifactLabel = artifacts ? ` \u2192 ${artifacts}` : "";
|
|
26335
|
+
lines.push(`### ${issue2.key} \u2014 ${issue2.summary} [${issue2.currentStatus}]${artifactLabel}`);
|
|
26336
|
+
lines.push(` Type: ${issue2.issueType} | Assignee: ${issue2.assignee ?? "unassigned"}`);
|
|
26337
|
+
for (const a of issue2.marvinArtifacts) {
|
|
26338
|
+
if (a.statusDrift) {
|
|
26339
|
+
lines.push(` \u26A0 ${a.id} status drift: Marvin="${a.currentStatus}" vs proposed="${a.proposedStatus}"`);
|
|
26340
|
+
}
|
|
26341
|
+
}
|
|
26342
|
+
if (issue2.changes.length > 0) {
|
|
26343
|
+
lines.push(" Changes:");
|
|
26344
|
+
for (const c of issue2.changes) {
|
|
26345
|
+
lines.push(` ${c.field}: ${c.from ?? "\u2205"} \u2192 ${c.to ?? "\u2205"} (${c.author}, ${c.timestamp.slice(0, 16)})`);
|
|
26346
|
+
}
|
|
26347
|
+
}
|
|
26348
|
+
if (issue2.comments.length > 0) {
|
|
26349
|
+
lines.push(` Comments (${issue2.comments.length}):`);
|
|
26350
|
+
for (const c of issue2.comments) {
|
|
26351
|
+
let signalIcons = "";
|
|
26352
|
+
if (c.signals.length > 0) {
|
|
26353
|
+
const icons = c.signals.map(
|
|
26354
|
+
(s) => s.type === "blocker" ? "\u{1F6AB}" : s.type === "decision" ? "\u2696" : s.type === "question" ? "?" : "\u2713"
|
|
26355
|
+
);
|
|
26356
|
+
signalIcons = ` [${icons.join("")}]`;
|
|
26357
|
+
}
|
|
26358
|
+
lines.push(` ${c.author} (${c.created.slice(0, 16)})${signalIcons}: ${c.bodyPreview}`);
|
|
26359
|
+
}
|
|
26360
|
+
}
|
|
26361
|
+
if (issue2.linkSuggestions.length > 0) {
|
|
26362
|
+
lines.push(" Possible Marvin matches:");
|
|
26363
|
+
for (const s of issue2.linkSuggestions) {
|
|
26364
|
+
lines.push(` \u{1F517} ${s.artifactId} ("${s.artifactTitle}") \u2014 ${Math.round(s.score * 100)}% match [${s.sharedTerms.join(", ")}]`);
|
|
26365
|
+
}
|
|
26366
|
+
}
|
|
26367
|
+
if (issue2.linkedIssues.length > 0) {
|
|
26368
|
+
lines.push(" Linked issues:");
|
|
26369
|
+
for (const li of issue2.linkedIssues) {
|
|
26370
|
+
const icon = li.isDone ? "\u2713" : "\u25CB";
|
|
26371
|
+
lines.push(` ${icon} ${li.key} ${li.summary} [${li.relationship}] \u2014 ${li.status}`);
|
|
26372
|
+
}
|
|
26373
|
+
}
|
|
26374
|
+
if (issue2.confluenceLinks.length > 0) {
|
|
26375
|
+
lines.push(" Confluence pages:");
|
|
26376
|
+
for (const cl of issue2.confluenceLinks) {
|
|
26377
|
+
lines.push(` \u{1F4C4} ${cl.title}: ${cl.url}`);
|
|
26378
|
+
}
|
|
26379
|
+
}
|
|
26380
|
+
lines.push("");
|
|
26381
|
+
return lines.join("\n");
|
|
26382
|
+
}
|
|
24899
26383
|
|
|
24900
26384
|
// src/skills/builtin/jira/index.ts
|
|
26385
|
+
var COMMON_TOOLS = `**Available tools:**
|
|
26386
|
+
- \`list_jira_issues\` / \`get_jira_issue\` \u2014 browse locally synced Jira issues (JI-xxx documents)
|
|
26387
|
+
- \`pull_jira_issue\` / \`pull_jira_issues_jql\` \u2014 import issues from Jira by key or JQL query
|
|
26388
|
+
- \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact. For **actions and tasks**, links directly via \`jiraKey\` on the artifact (no JI-xxx intermediary). For other types, creates a JI-xxx tracking document.
|
|
26389
|
+
- \`link_to_jira\` \u2014 link an existing Jira issue to a Marvin action or task (sets \`jiraKey\` directly on the artifact)
|
|
26390
|
+
- \`fetch_jira_status\` \u2014 **read-only**: fetch current Jira status, subtask progress, and linked issues for Jira-linked actions/tasks. Returns proposed changes without applying them.
|
|
26391
|
+
- \`fetch_jira_daily\` \u2014 **read-only**: fetch a daily/range summary of all Jira changes \u2014 status transitions, comments, linked Confluence pages, and cross-references with Marvin artifacts. Returns proposed actions (status updates, unlinked issues, question candidates, Confluence pages to review).
|
|
26392
|
+
- \`fetch_jira_statuses\` \u2014 **read-only**: discover all Jira statuses in a project and show their Marvin mappings (mapped vs unmapped).
|
|
26393
|
+
- \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
|
|
26394
|
+
- \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx`;
|
|
26395
|
+
var COMMON_WORKFLOW = `**Jira sync workflow:**
|
|
26396
|
+
1. Call \`fetch_jira_status\` to see what Jira reports for linked artifacts
|
|
26397
|
+
2. Analyze the proposed changes (status transitions, subtask progress, blockers from linked issues)
|
|
26398
|
+
3. Use \`update_action\` / \`update_task\` to apply the changes you agree with
|
|
26399
|
+
|
|
26400
|
+
**Daily review workflow:**
|
|
26401
|
+
1. Call \`fetch_jira_daily\` (optionally with \`from\`/\`to\` date range) to get a summary of all Jira activity
|
|
26402
|
+
2. Review the proposed actions: status updates, unlinked issues to track, questions that may be answered, Confluence pages to review
|
|
26403
|
+
3. Use existing tools to apply changes, create new artifacts, or link untracked issues`;
|
|
24901
26404
|
var jiraSkill = {
|
|
24902
26405
|
id: "jira",
|
|
24903
26406
|
name: "Jira Integration",
|
|
24904
26407
|
description: "Bidirectional sync between Marvin artifacts and Jira issues",
|
|
24905
26408
|
version: "1.0.0",
|
|
24906
26409
|
format: "builtin-ts",
|
|
24907
|
-
// No default persona affinity — opt-in via config.yaml skills section
|
|
24908
26410
|
documentTypeRegistrations: [
|
|
24909
26411
|
{ type: "jira-issue", dirName: "jira-issues", idPrefix: "JI" }
|
|
24910
26412
|
],
|
|
24911
26413
|
tools: (store, projectConfig) => createJiraTools(store, projectConfig),
|
|
24912
26414
|
promptFragments: {
|
|
24913
|
-
"product-owner": `You have the **Jira Integration** skill.
|
|
26415
|
+
"product-owner": `You have the **Jira Integration** skill.
|
|
24914
26416
|
|
|
24915
|
-
|
|
24916
|
-
|
|
24917
|
-
|
|
24918
|
-
- \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, feature, etc.). The \`projectKey\` parameter is optional when a default is configured in \`.marvin/config.yaml\` under \`jira.projectKey\`.
|
|
24919
|
-
- \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
|
|
24920
|
-
- \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx
|
|
26417
|
+
${COMMON_TOOLS}
|
|
26418
|
+
|
|
26419
|
+
${COMMON_WORKFLOW}
|
|
24921
26420
|
|
|
24922
26421
|
**As Product Owner, use Jira integration to:**
|
|
26422
|
+
- Use \`fetch_jira_daily\` for daily standups \u2014 review what changed, identify status drift, spot untracked work
|
|
24923
26423
|
- Pull stakeholder-reported issues for triage and prioritization
|
|
24924
26424
|
- Push approved features as Stories for development tracking
|
|
24925
26425
|
- Link decisions to Jira issues for audit trail and traceability
|
|
24926
|
-
- Use
|
|
24927
|
-
"tech-lead": `You have the **Jira Integration** skill.
|
|
26426
|
+
- Use \`fetch_jira_statuses\` when setting up a new project to configure status mappings`,
|
|
26427
|
+
"tech-lead": `You have the **Jira Integration** skill.
|
|
24928
26428
|
|
|
24929
|
-
|
|
24930
|
-
|
|
24931
|
-
|
|
24932
|
-
- \`push_artifact_to_jira\` \u2014 create a Jira issue from a Marvin artifact (decision, action, epic, task, etc.). The \`projectKey\` parameter is optional when a default is configured in \`.marvin/config.yaml\` under \`jira.projectKey\`.
|
|
24933
|
-
- \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
|
|
24934
|
-
- \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx
|
|
26429
|
+
${COMMON_TOOLS}
|
|
26430
|
+
|
|
26431
|
+
${COMMON_WORKFLOW}
|
|
24935
26432
|
|
|
24936
26433
|
**As Tech Lead, use Jira integration to:**
|
|
26434
|
+
- Use \`fetch_jira_daily\` to review technical progress \u2014 status transitions, new comments, Confluence design docs
|
|
24937
26435
|
- Pull technical issues and bugs for sprint planning and estimation
|
|
24938
26436
|
- Push epics, tasks, and technical decisions to Jira for cross-team visibility
|
|
24939
|
-
-
|
|
24940
|
-
- Use
|
|
24941
|
-
"delivery-manager": `You have the **Jira Integration** skill.
|
|
26437
|
+
- Use \`link_to_jira\` to connect Marvin tasks to existing Jira tickets
|
|
26438
|
+
- Use \`fetch_jira_statuses\` to verify status mappings match the team's Jira workflow`,
|
|
26439
|
+
"delivery-manager": `You have the **Jira Integration** skill.
|
|
24942
26440
|
|
|
24943
|
-
|
|
24944
|
-
|
|
24945
|
-
|
|
24946
|
-
|
|
24947
|
-
- \`sync_jira_issue\` \u2014 bidirectional sync of a local JI-xxx with Jira
|
|
24948
|
-
- \`link_artifact_to_jira\` \u2014 link a Marvin artifact to an existing JI-xxx
|
|
26441
|
+
${COMMON_TOOLS}
|
|
26442
|
+
|
|
26443
|
+
${COMMON_WORKFLOW}
|
|
26444
|
+
This is a third path for progress tracking alongside Contributions and Meetings.
|
|
24949
26445
|
|
|
24950
26446
|
**As Delivery Manager, use Jira integration to:**
|
|
26447
|
+
- Use \`fetch_jira_daily\` for daily progress reports \u2014 track what moved, identify blockers, spot untracked work
|
|
24951
26448
|
- Pull sprint issues for tracking progress and blockers
|
|
24952
|
-
- Push actions
|
|
24953
|
-
- Use
|
|
24954
|
-
-
|
|
26449
|
+
- Push actions and tasks to Jira for stakeholder visibility
|
|
26450
|
+
- Use \`fetch_jira_daily\` with a date range for sprint retrospectives (e.g. \`from: "2026-03-10", to: "2026-03-21"\`)
|
|
26451
|
+
- Use \`fetch_jira_statuses\` to ensure Jira workflow statuses are properly mapped`
|
|
24955
26452
|
}
|
|
24956
26453
|
};
|
|
24957
26454
|
|
|
@@ -25151,8 +26648,8 @@ function generateTaskMasterPrd(title, ctx, projectOverview) {
|
|
|
25151
26648
|
let priorityIdx = 1;
|
|
25152
26649
|
for (const feature of ctx.features) {
|
|
25153
26650
|
const featureEpics = ctx.epics.filter((e) => e.linkedFeature.includes(feature.id)).sort((a, b) => {
|
|
25154
|
-
const
|
|
25155
|
-
return (
|
|
26651
|
+
const statusOrder2 = { "in-progress": 0, planned: 1, done: 2 };
|
|
26652
|
+
return (statusOrder2[a.status] ?? 99) - (statusOrder2[b.status] ?? 99);
|
|
25156
26653
|
});
|
|
25157
26654
|
if (featureEpics.length === 0) continue;
|
|
25158
26655
|
lines.push(`${priorityIdx}. **${feature.title}** (${feature.priority})`);
|
|
@@ -30069,12 +31566,355 @@ Run "marvin doctor --fix" to auto-repair fixable issues.`));
|
|
|
30069
31566
|
console.log();
|
|
30070
31567
|
}
|
|
30071
31568
|
|
|
31569
|
+
// src/cli/commands/jira.ts
|
|
31570
|
+
import chalk20 from "chalk";
|
|
31571
|
+
async function jiraSyncCommand(artifactId, options = {}) {
|
|
31572
|
+
const project = loadProject();
|
|
31573
|
+
const plugin = resolvePlugin(project.config.methodology);
|
|
31574
|
+
const registrations = plugin?.documentTypeRegistrations ?? [];
|
|
31575
|
+
const jiReg = { type: "jira-issue", dirName: "jira-issues", idPrefix: "JI" };
|
|
31576
|
+
const store = new DocumentStore(project.marvinDir, [...registrations, jiReg]);
|
|
31577
|
+
const jiraUserConfig = loadUserConfig().jira;
|
|
31578
|
+
const jira = createJiraClient(jiraUserConfig);
|
|
31579
|
+
if (!jira) {
|
|
31580
|
+
console.log(
|
|
31581
|
+
chalk20.red(
|
|
31582
|
+
'Jira is not configured. Run "marvin config jira" or set JIRA_HOST, JIRA_EMAIL, and JIRA_API_TOKEN environment variables.'
|
|
31583
|
+
)
|
|
31584
|
+
);
|
|
31585
|
+
return;
|
|
31586
|
+
}
|
|
31587
|
+
const statusMap = project.config.jira?.statusMap;
|
|
31588
|
+
const label = artifactId ? `Checking ${artifactId} against Jira...` : "Checking all Jira-linked actions/tasks...";
|
|
31589
|
+
console.log(chalk20.dim(label));
|
|
31590
|
+
if (options.dryRun) {
|
|
31591
|
+
const fetchResult = await fetchJiraStatus(
|
|
31592
|
+
store,
|
|
31593
|
+
jira.client,
|
|
31594
|
+
jira.host,
|
|
31595
|
+
artifactId,
|
|
31596
|
+
statusMap
|
|
31597
|
+
);
|
|
31598
|
+
const withChanges = fetchResult.artifacts.filter(
|
|
31599
|
+
(a) => a.statusChanged || a.progressChanged
|
|
31600
|
+
);
|
|
31601
|
+
const noChanges = fetchResult.artifacts.filter(
|
|
31602
|
+
(a) => !a.statusChanged && !a.progressChanged
|
|
31603
|
+
);
|
|
31604
|
+
if (withChanges.length > 0) {
|
|
31605
|
+
console.log(chalk20.yellow(`
|
|
31606
|
+
Proposed changes for ${withChanges.length} artifact(s):`));
|
|
31607
|
+
for (const a of withChanges) {
|
|
31608
|
+
console.log(` ${chalk20.bold(a.id)} (${a.jiraKey}) \u2014 Jira: "${a.jiraSummary}"`);
|
|
31609
|
+
if (a.statusChanged) {
|
|
31610
|
+
console.log(
|
|
31611
|
+
` status: ${chalk20.yellow(a.currentMarvinStatus)} \u2192 ${chalk20.green(a.proposedMarvinStatus)}`
|
|
31612
|
+
);
|
|
31613
|
+
}
|
|
31614
|
+
if (a.progressChanged) {
|
|
31615
|
+
console.log(
|
|
31616
|
+
` progress: ${chalk20.yellow(String(a.currentProgress ?? 0) + "%")} \u2192 ${chalk20.green(String(a.proposedProgress) + "%")}`
|
|
31617
|
+
);
|
|
31618
|
+
}
|
|
31619
|
+
if (a.linkedIssues.length > 0) {
|
|
31620
|
+
const done = a.linkedIssues.filter((l) => l.isDone).length;
|
|
31621
|
+
console.log(chalk20.dim(` ${done}/${a.linkedIssues.length} linked issues done`));
|
|
31622
|
+
}
|
|
31623
|
+
}
|
|
31624
|
+
console.log(chalk20.dim("\nRun without --dry-run to apply these changes."));
|
|
31625
|
+
}
|
|
31626
|
+
if (noChanges.length > 0) {
|
|
31627
|
+
console.log(chalk20.dim(`
|
|
31628
|
+
${noChanges.length} artifact(s) already in sync.`));
|
|
31629
|
+
}
|
|
31630
|
+
if (fetchResult.errors.length > 0) {
|
|
31631
|
+
console.log(chalk20.red("\nErrors:"));
|
|
31632
|
+
for (const err of fetchResult.errors) {
|
|
31633
|
+
console.log(chalk20.red(` ${err}`));
|
|
31634
|
+
}
|
|
31635
|
+
}
|
|
31636
|
+
if (fetchResult.artifacts.length === 0 && fetchResult.errors.length === 0) {
|
|
31637
|
+
console.log(chalk20.dim("\nNo Jira-linked actions/tasks found to check."));
|
|
31638
|
+
}
|
|
31639
|
+
return;
|
|
31640
|
+
}
|
|
31641
|
+
const result = await syncJiraProgress(
|
|
31642
|
+
store,
|
|
31643
|
+
jira.client,
|
|
31644
|
+
jira.host,
|
|
31645
|
+
artifactId,
|
|
31646
|
+
statusMap
|
|
31647
|
+
);
|
|
31648
|
+
if (result.updated.length > 0) {
|
|
31649
|
+
console.log(chalk20.green(`
|
|
31650
|
+
Updated ${result.updated.length} artifact(s):`));
|
|
31651
|
+
for (const entry of result.updated) {
|
|
31652
|
+
const statusChange = entry.oldStatus !== entry.newStatus ? `${chalk20.yellow(entry.oldStatus)} \u2192 ${chalk20.green(entry.newStatus)}` : chalk20.dim(entry.newStatus);
|
|
31653
|
+
console.log(` ${chalk20.bold(entry.id)} (${entry.jiraKey}): ${statusChange}`);
|
|
31654
|
+
if (entry.linkedIssues.length > 0) {
|
|
31655
|
+
const done = entry.linkedIssues.filter((l) => l.isDone).length;
|
|
31656
|
+
console.log(
|
|
31657
|
+
chalk20.dim(` ${done}/${entry.linkedIssues.length} linked issues done`)
|
|
31658
|
+
);
|
|
31659
|
+
for (const li of entry.linkedIssues) {
|
|
31660
|
+
const icon = li.isDone ? chalk20.green("\u2713") : chalk20.dim("\u25CB");
|
|
31661
|
+
console.log(
|
|
31662
|
+
chalk20.dim(` ${icon} ${li.key} ${li.summary} [${li.relationship}]`)
|
|
31663
|
+
);
|
|
31664
|
+
}
|
|
31665
|
+
}
|
|
31666
|
+
}
|
|
31667
|
+
}
|
|
31668
|
+
if (result.unchanged > 0) {
|
|
31669
|
+
console.log(chalk20.dim(`
|
|
31670
|
+
${result.unchanged} artifact(s) unchanged.`));
|
|
31671
|
+
}
|
|
31672
|
+
if (result.errors.length > 0) {
|
|
31673
|
+
console.log(chalk20.red("\nErrors:"));
|
|
31674
|
+
for (const err of result.errors) {
|
|
31675
|
+
console.log(chalk20.red(` ${err}`));
|
|
31676
|
+
}
|
|
31677
|
+
}
|
|
31678
|
+
if (result.updated.length === 0 && result.unchanged === 0 && result.errors.length === 0) {
|
|
31679
|
+
console.log(chalk20.dim("\nNo Jira-linked actions/tasks found to sync."));
|
|
31680
|
+
}
|
|
31681
|
+
}
|
|
31682
|
+
async function jiraStatusesCommand(projectKey) {
|
|
31683
|
+
const project = loadProject();
|
|
31684
|
+
const jiraUserConfig = loadUserConfig().jira;
|
|
31685
|
+
const jira = createJiraClient(jiraUserConfig);
|
|
31686
|
+
if (!jira) {
|
|
31687
|
+
console.log(
|
|
31688
|
+
chalk20.red(
|
|
31689
|
+
'Jira is not configured. Run "marvin config jira" or set JIRA_HOST, JIRA_EMAIL, and JIRA_API_TOKEN environment variables.'
|
|
31690
|
+
)
|
|
31691
|
+
);
|
|
31692
|
+
return;
|
|
31693
|
+
}
|
|
31694
|
+
const resolvedProjectKey = projectKey ?? project.config.jira?.projectKey;
|
|
31695
|
+
if (!resolvedProjectKey) {
|
|
31696
|
+
console.log(
|
|
31697
|
+
chalk20.red(
|
|
31698
|
+
"No project key provided. Pass it as an argument or set jira.projectKey in .marvin/config.yaml."
|
|
31699
|
+
)
|
|
31700
|
+
);
|
|
31701
|
+
return;
|
|
31702
|
+
}
|
|
31703
|
+
console.log(chalk20.dim(`Fetching statuses from Jira project ${resolvedProjectKey}...`));
|
|
31704
|
+
const statusMap = project.config.jira?.statusMap;
|
|
31705
|
+
const actionMap = statusMap?.action ?? DEFAULT_ACTION_STATUS_MAP;
|
|
31706
|
+
const taskMap = statusMap?.task ?? DEFAULT_TASK_STATUS_MAP;
|
|
31707
|
+
const email3 = jiraUserConfig?.email ?? process.env.JIRA_EMAIL;
|
|
31708
|
+
const apiToken = jiraUserConfig?.apiToken ?? process.env.JIRA_API_TOKEN;
|
|
31709
|
+
const auth = "Basic " + Buffer.from(`${email3}:${apiToken}`).toString("base64");
|
|
31710
|
+
const params = new URLSearchParams({
|
|
31711
|
+
jql: `project = ${resolvedProjectKey}`,
|
|
31712
|
+
maxResults: "100",
|
|
31713
|
+
fields: "status"
|
|
31714
|
+
});
|
|
31715
|
+
const resp = await fetch(`https://${jira.host}/rest/api/3/search/jql?${params}`, {
|
|
31716
|
+
headers: { Authorization: auth, Accept: "application/json" }
|
|
31717
|
+
});
|
|
31718
|
+
if (!resp.ok) {
|
|
31719
|
+
const text = await resp.text().catch(() => "");
|
|
31720
|
+
console.log(chalk20.red(`Jira API error ${resp.status}: ${text}`));
|
|
31721
|
+
return;
|
|
31722
|
+
}
|
|
31723
|
+
const data = await resp.json();
|
|
31724
|
+
const statusCounts = /* @__PURE__ */ new Map();
|
|
31725
|
+
for (const issue2 of data.issues) {
|
|
31726
|
+
const s = issue2.fields.status.name;
|
|
31727
|
+
statusCounts.set(s, (statusCounts.get(s) ?? 0) + 1);
|
|
31728
|
+
}
|
|
31729
|
+
const actionLookup = /* @__PURE__ */ new Map();
|
|
31730
|
+
for (const [marvin, jiraStatuses] of Object.entries(actionMap)) {
|
|
31731
|
+
for (const js of jiraStatuses) actionLookup.set(js.toLowerCase(), marvin);
|
|
31732
|
+
}
|
|
31733
|
+
const taskLookup = /* @__PURE__ */ new Map();
|
|
31734
|
+
for (const [marvin, jiraStatuses] of Object.entries(taskMap)) {
|
|
31735
|
+
for (const js of jiraStatuses) taskLookup.set(js.toLowerCase(), marvin);
|
|
31736
|
+
}
|
|
31737
|
+
console.log(
|
|
31738
|
+
`
|
|
31739
|
+
Found ${chalk20.bold(String(statusCounts.size))} distinct statuses in ${chalk20.bold(resolvedProjectKey)} (scanned ${data.issues.length} of ${data.total} issues):
|
|
31740
|
+
`
|
|
31741
|
+
);
|
|
31742
|
+
const sorted = [...statusCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
31743
|
+
let hasUnmapped = false;
|
|
31744
|
+
for (const [status, count] of sorted) {
|
|
31745
|
+
const actionTarget = actionLookup.get(status.toLowerCase());
|
|
31746
|
+
const taskTarget = taskLookup.get(status.toLowerCase());
|
|
31747
|
+
const actionLabel = actionTarget ? chalk20.green(`\u2192 ${actionTarget}`) : chalk20.yellow("UNMAPPED (\u2192 open)");
|
|
31748
|
+
const taskLabel = taskTarget ? chalk20.green(`\u2192 ${taskTarget}`) : chalk20.yellow("UNMAPPED (\u2192 backlog)");
|
|
31749
|
+
if (!actionTarget || !taskTarget) hasUnmapped = true;
|
|
31750
|
+
console.log(` ${chalk20.bold(status)} ${chalk20.dim(`(${count} issues)`)}`);
|
|
31751
|
+
console.log(` action: ${actionLabel}`);
|
|
31752
|
+
console.log(` task: ${taskLabel}`);
|
|
31753
|
+
}
|
|
31754
|
+
if (hasUnmapped) {
|
|
31755
|
+
console.log(chalk20.yellow("\nSome statuses are unmapped. Add jira.statusMap to .marvin/config.yaml:"));
|
|
31756
|
+
console.log(chalk20.dim(" jira:"));
|
|
31757
|
+
console.log(chalk20.dim(" statusMap:"));
|
|
31758
|
+
console.log(chalk20.dim(" action:"));
|
|
31759
|
+
console.log(chalk20.dim(' <marvin-status>: ["<Jira Status>", ...]'));
|
|
31760
|
+
console.log(chalk20.dim(" task:"));
|
|
31761
|
+
console.log(chalk20.dim(' <marvin-status>: ["<Jira Status>", ...]'));
|
|
31762
|
+
} else {
|
|
31763
|
+
console.log(chalk20.green("\nAll statuses are mapped."));
|
|
31764
|
+
}
|
|
31765
|
+
const usingConfig = statusMap?.action || statusMap?.task;
|
|
31766
|
+
console.log(
|
|
31767
|
+
chalk20.dim(
|
|
31768
|
+
usingConfig ? "\nUsing status maps from .marvin/config.yaml." : "\nUsing built-in default status maps (no jira.statusMap in config)."
|
|
31769
|
+
)
|
|
31770
|
+
);
|
|
31771
|
+
}
|
|
31772
|
+
async function jiraDailyCommand(options) {
|
|
31773
|
+
const proj = loadProject();
|
|
31774
|
+
const plugin = resolvePlugin(proj.config.methodology);
|
|
31775
|
+
const registrations = plugin?.documentTypeRegistrations ?? [];
|
|
31776
|
+
const jiReg = { type: "jira-issue", dirName: "jira-issues", idPrefix: "JI" };
|
|
31777
|
+
const store = new DocumentStore(proj.marvinDir, [...registrations, jiReg]);
|
|
31778
|
+
const jiraUserConfig = loadUserConfig().jira;
|
|
31779
|
+
const jira = createJiraClient(jiraUserConfig);
|
|
31780
|
+
if (!jira) {
|
|
31781
|
+
console.log(
|
|
31782
|
+
chalk20.red(
|
|
31783
|
+
'Jira is not configured. Run "marvin config jira" or set JIRA_HOST, JIRA_EMAIL, and JIRA_API_TOKEN environment variables.'
|
|
31784
|
+
)
|
|
31785
|
+
);
|
|
31786
|
+
return;
|
|
31787
|
+
}
|
|
31788
|
+
const resolvedProjectKey = options.project ?? proj.config.jira?.projectKey;
|
|
31789
|
+
if (!resolvedProjectKey) {
|
|
31790
|
+
console.log(
|
|
31791
|
+
chalk20.red(
|
|
31792
|
+
"No project key provided. Use --project or set jira.projectKey in .marvin/config.yaml."
|
|
31793
|
+
)
|
|
31794
|
+
);
|
|
31795
|
+
return;
|
|
31796
|
+
}
|
|
31797
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
31798
|
+
const fromDate = options.from ?? today;
|
|
31799
|
+
const toDate = options.to ?? fromDate;
|
|
31800
|
+
const statusMap = proj.config.jira?.statusMap;
|
|
31801
|
+
const rangeLabel = fromDate === toDate ? fromDate : `${fromDate} to ${toDate}`;
|
|
31802
|
+
console.log(
|
|
31803
|
+
chalk20.dim(`Fetching Jira daily summary for ${resolvedProjectKey} \u2014 ${rangeLabel}...`)
|
|
31804
|
+
);
|
|
31805
|
+
const daily = await fetchJiraDaily(
|
|
31806
|
+
store,
|
|
31807
|
+
jira.client,
|
|
31808
|
+
jira.host,
|
|
31809
|
+
resolvedProjectKey,
|
|
31810
|
+
{ from: fromDate, to: toDate },
|
|
31811
|
+
statusMap
|
|
31812
|
+
);
|
|
31813
|
+
console.log(
|
|
31814
|
+
`
|
|
31815
|
+
${chalk20.bold(`Jira Daily \u2014 ${resolvedProjectKey} \u2014 ${rangeLabel}`)}`
|
|
31816
|
+
);
|
|
31817
|
+
console.log(`${daily.issues.length} issue(s) updated.
|
|
31818
|
+
`);
|
|
31819
|
+
const linked = daily.issues.filter((i) => i.marvinArtifacts.length > 0);
|
|
31820
|
+
const unlinked = daily.issues.filter((i) => i.marvinArtifacts.length === 0);
|
|
31821
|
+
if (linked.length > 0) {
|
|
31822
|
+
console.log(chalk20.underline("Linked Issues (with Marvin artifacts):\n"));
|
|
31823
|
+
for (const issue2 of linked) {
|
|
31824
|
+
printIssueEntry(issue2);
|
|
31825
|
+
}
|
|
31826
|
+
}
|
|
31827
|
+
if (unlinked.length > 0) {
|
|
31828
|
+
console.log(chalk20.underline("Unlinked Issues (no Marvin artifact):\n"));
|
|
31829
|
+
for (const issue2 of unlinked) {
|
|
31830
|
+
printIssueEntry(issue2);
|
|
31831
|
+
}
|
|
31832
|
+
}
|
|
31833
|
+
if (daily.proposedActions.length > 0) {
|
|
31834
|
+
console.log(chalk20.underline("Proposed Actions:\n"));
|
|
31835
|
+
for (const action of daily.proposedActions) {
|
|
31836
|
+
const icon = action.type === "status-update" ? chalk20.yellow("\u21BB") : action.type === "unlinked-issue" ? chalk20.blue("+") : action.type === "link-suggestion" ? chalk20.cyan("\u{1F517}") : action.type === "question-candidate" ? chalk20.magenta("?") : action.type === "decision-candidate" ? chalk20.yellow("\u2696") : action.type === "blocker-detected" ? chalk20.red("\u{1F6AB}") : action.type === "resolution-detected" ? chalk20.green("\u2713") : chalk20.cyan("\u{1F4C4}");
|
|
31837
|
+
console.log(` ${icon} ${action.description}`);
|
|
31838
|
+
}
|
|
31839
|
+
console.log();
|
|
31840
|
+
}
|
|
31841
|
+
if (daily.errors.length > 0) {
|
|
31842
|
+
console.log(chalk20.red("Errors:"));
|
|
31843
|
+
for (const err of daily.errors) {
|
|
31844
|
+
console.log(chalk20.red(` ${err}`));
|
|
31845
|
+
}
|
|
31846
|
+
}
|
|
31847
|
+
if (daily.issues.length === 0 && daily.errors.length === 0) {
|
|
31848
|
+
console.log(chalk20.dim("No Jira activity found for this period."));
|
|
31849
|
+
}
|
|
31850
|
+
}
|
|
31851
|
+
function printIssueEntry(issue2) {
|
|
31852
|
+
const artifacts = issue2.marvinArtifacts.map((a) => a.id).join(", ");
|
|
31853
|
+
const artifactLabel = artifacts ? chalk20.cyan(` \u2192 ${artifacts}`) : "";
|
|
31854
|
+
console.log(
|
|
31855
|
+
` ${chalk20.bold(issue2.key)} \u2014 ${issue2.summary} [${chalk20.yellow(issue2.currentStatus)}]${artifactLabel}`
|
|
31856
|
+
);
|
|
31857
|
+
console.log(
|
|
31858
|
+
chalk20.dim(` Type: ${issue2.issueType} | Assignee: ${issue2.assignee ?? "unassigned"}`)
|
|
31859
|
+
);
|
|
31860
|
+
for (const a of issue2.marvinArtifacts) {
|
|
31861
|
+
if (a.statusDrift) {
|
|
31862
|
+
console.log(
|
|
31863
|
+
chalk20.yellow(` \u26A0 ${a.id} status drift: Marvin="${a.currentStatus}" vs proposed="${a.proposedStatus}"`)
|
|
31864
|
+
);
|
|
31865
|
+
}
|
|
31866
|
+
}
|
|
31867
|
+
if (issue2.changes.length > 0) {
|
|
31868
|
+
console.log(chalk20.dim(" Changes:"));
|
|
31869
|
+
for (const c of issue2.changes) {
|
|
31870
|
+
console.log(
|
|
31871
|
+
chalk20.dim(` ${c.field}: ${c.from ?? "\u2205"} \u2192 ${c.to ?? "\u2205"} (${c.author}, ${c.timestamp.slice(0, 16)})`)
|
|
31872
|
+
);
|
|
31873
|
+
}
|
|
31874
|
+
}
|
|
31875
|
+
if (issue2.comments.length > 0) {
|
|
31876
|
+
console.log(chalk20.dim(` Comments (${issue2.comments.length}):`));
|
|
31877
|
+
for (const c of issue2.comments) {
|
|
31878
|
+
let signalLabel = "";
|
|
31879
|
+
if (c.signals.length > 0) {
|
|
31880
|
+
const labels = c.signals.map(
|
|
31881
|
+
(s) => s.type === "blocker" ? chalk20.red("\u{1F6AB}blocker") : s.type === "decision" ? chalk20.yellow("\u2696decision") : s.type === "question" ? chalk20.magenta("?question") : chalk20.green("\u2713resolution")
|
|
31882
|
+
);
|
|
31883
|
+
signalLabel = ` ${labels.join(" ")}`;
|
|
31884
|
+
}
|
|
31885
|
+
console.log(chalk20.dim(` ${c.author} (${c.created.slice(0, 16)})${signalLabel}: ${c.bodyPreview}`));
|
|
31886
|
+
}
|
|
31887
|
+
}
|
|
31888
|
+
if (issue2.linkSuggestions.length > 0) {
|
|
31889
|
+
console.log(chalk20.cyan(" Possible Marvin matches:"));
|
|
31890
|
+
for (const s of issue2.linkSuggestions) {
|
|
31891
|
+
console.log(
|
|
31892
|
+
chalk20.cyan(` \u{1F517} ${s.artifactId} ("${s.artifactTitle}") \u2014 ${Math.round(s.score * 100)}% match [${s.sharedTerms.join(", ")}]`)
|
|
31893
|
+
);
|
|
31894
|
+
}
|
|
31895
|
+
}
|
|
31896
|
+
if (issue2.linkedIssues.length > 0) {
|
|
31897
|
+
console.log(chalk20.dim(" Linked issues:"));
|
|
31898
|
+
for (const li of issue2.linkedIssues) {
|
|
31899
|
+
const icon = li.isDone ? chalk20.green("\u2713") : chalk20.dim("\u25CB");
|
|
31900
|
+
console.log(chalk20.dim(` ${icon} ${li.key} ${li.summary} [${li.relationship}] \u2014 ${li.status}`));
|
|
31901
|
+
}
|
|
31902
|
+
}
|
|
31903
|
+
if (issue2.confluenceLinks.length > 0) {
|
|
31904
|
+
console.log(chalk20.dim(" Confluence pages:"));
|
|
31905
|
+
for (const cl of issue2.confluenceLinks) {
|
|
31906
|
+
console.log(chalk20.dim(` \u{1F4C4} ${cl.title}: ${cl.url}`));
|
|
31907
|
+
}
|
|
31908
|
+
}
|
|
31909
|
+
console.log();
|
|
31910
|
+
}
|
|
31911
|
+
|
|
30072
31912
|
// src/cli/program.ts
|
|
30073
31913
|
function createProgram() {
|
|
30074
31914
|
const program = new Command();
|
|
30075
31915
|
program.name("marvin").description(
|
|
30076
31916
|
"AI-powered product development assistant with Product Owner, Delivery Manager, and Technical Lead personas"
|
|
30077
|
-
).version("0.5.
|
|
31917
|
+
).version("0.5.8");
|
|
30078
31918
|
program.command("init").description("Initialize a new Marvin project in the current directory").action(async () => {
|
|
30079
31919
|
await initCommand();
|
|
30080
31920
|
});
|
|
@@ -30170,6 +32010,16 @@ function createProgram() {
|
|
|
30170
32010
|
generateCmd.command("claude-md").description("Generate .marvin/CLAUDE.md project instruction file").option("--force", "Overwrite existing file without prompting").action(async (options) => {
|
|
30171
32011
|
await generateClaudeMdCommand(options);
|
|
30172
32012
|
});
|
|
32013
|
+
const jiraCmd = program.command("jira").description("Jira integration commands");
|
|
32014
|
+
jiraCmd.command("sync [artifactId]").description("Sync Jira-linked actions/tasks with their Jira issues").option("--dry-run", "Preview proposed changes without applying them").action(async (artifactId, options) => {
|
|
32015
|
+
await jiraSyncCommand(artifactId, options);
|
|
32016
|
+
});
|
|
32017
|
+
jiraCmd.command("statuses [projectKey]").description("Show Jira project statuses and their Marvin status mappings").action(async (projectKey) => {
|
|
32018
|
+
await jiraStatusesCommand(projectKey);
|
|
32019
|
+
});
|
|
32020
|
+
jiraCmd.command("daily").description("Show daily summary of Jira changes with Marvin cross-references").option("--from <date>", "Start date (YYYY-MM-DD, default: today)").option("--to <date>", "End date (YYYY-MM-DD, default: same as --from)").option("--project <key>", "Jira project key (falls back to config)").action(async (options) => {
|
|
32021
|
+
await jiraDailyCommand(options);
|
|
32022
|
+
});
|
|
30173
32023
|
return program;
|
|
30174
32024
|
}
|
|
30175
32025
|
export {
|