ralph-hero-mcp-server 2.5.94 → 2.5.106

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.js CHANGED
@@ -29,6 +29,7 @@ import { registerDecomposeTools } from "./tools/decompose-tools.js";
29
29
  import { registerViewTools } from "./tools/view-tools.js";
30
30
  import { registerPlanGraphTools } from "./tools/plan-graph-tools.js";
31
31
  import { registerActivityTools } from "./tools/activity-tools.js";
32
+ import { registerTrendsTools } from "./tools/trends-tools.js";
32
33
  /**
33
34
  * Initialize the GitHub client from environment variables.
34
35
  */
@@ -404,6 +405,8 @@ async function main() {
404
405
  registerPlanGraphTools(server, client);
405
406
  // Activity log reader (recent_activity tool — pure filesystem, no GitHub client)
406
407
  registerActivityTools(server);
408
+ // Trends tools (capture_snapshot — JSONL persistence under ~/.ralph-hero/snapshots/)
409
+ registerTrendsTools(server, client, fieldCache);
407
410
  // Debug tools (only when RALPH_DEBUG=true)
408
411
  if (process.env.RALPH_DEBUG === 'true') {
409
412
  registerDebugTools(server, client);
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Pure cycle-time rollup over `TransitionRecord[]` extracted from
3
+ * issue comments by `parseAllTransitions()`.
4
+ *
5
+ * Phase 2 (GH-1023) of the product-performance-over-time epic (#1019).
6
+ *
7
+ * Inputs are a list of `TransitionedIssue` (issueNumber + transitions
8
+ * + optional `closedAt`). Output is a `CycleTimeRollup` with p50/p90
9
+ * lead-time across issues and per-phase dwell percentiles.
10
+ *
11
+ * Semantics:
12
+ * - **Lead-time** per issue = `(last.at - first.at)` in hours. Issues
13
+ * with `< 2` valid transitions are excluded from the lead-time
14
+ * sample. `sampleSize` counts only included issues.
15
+ * - **Per-phase dwell** = consecutive `(transition[i+1].at -
16
+ * transition[i].at)` keyed by `transition[i].from`. The terminal
17
+ * state's dwell uses `closedAt ?? now` as the upper bound when the
18
+ * issue has not transitioned out of it. If `closedAt` is missing
19
+ * (issue still open), the open-ended terminal dwell is excluded.
20
+ * - **Out-of-order tolerance**: transitions are defensively sorted
21
+ * by `at` ascending before computation. Original arrays are not
22
+ * mutated.
23
+ * - **Malformed timestamps**: any transition whose `at` does not
24
+ * parse to a finite epoch (`Number.isFinite(Date.parse(at)) ===
25
+ * false`) is skipped without throwing.
26
+ * - **Percentiles**: linear-interpolation nearest-rank. With `n =
27
+ * 1`, both p50 and p90 equal the single value. With `n = 0`, the
28
+ * phase entry is omitted from `perPhaseDwellHours`; lead-time
29
+ * fields are `null`.
30
+ *
31
+ * Pure: no I/O, deterministic, safe to call from any context.
32
+ */
33
+ // ---------------------------------------------------------------------------
34
+ // Internals
35
+ // ---------------------------------------------------------------------------
36
+ const MS_PER_HOUR = 3_600_000;
37
+ /** Parse an ISO timestamp; return `null` when not finite. */
38
+ function parseAt(at) {
39
+ const ms = Date.parse(at);
40
+ return Number.isFinite(ms) ? ms : null;
41
+ }
42
+ /**
43
+ * Linear-interpolation nearest-rank percentile over a sorted (ascending)
44
+ * array of finite numbers. `p` is in [0, 1]. Caller must ensure the
45
+ * array is non-empty and sorted.
46
+ */
47
+ function percentile(sorted, p) {
48
+ const n = sorted.length;
49
+ if (n === 1)
50
+ return sorted[0];
51
+ // rank in [0, n-1] (zero-indexed)
52
+ const rank = p * (n - 1);
53
+ const lo = Math.floor(rank);
54
+ const hi = Math.ceil(rank);
55
+ if (lo === hi)
56
+ return sorted[lo];
57
+ const frac = rank - lo;
58
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * frac;
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Public API
62
+ // ---------------------------------------------------------------------------
63
+ /**
64
+ * Roll up lead-time and per-phase dwell-time across a batch of
65
+ * transitioned issues.
66
+ *
67
+ * @param records One entry per issue with parsed transitions.
68
+ * @param now Epoch milliseconds — used to bound the terminal-state
69
+ * dwell when `closedAt` is missing on a non-terminal
70
+ * issue. Always pass `Date.now()` from production code;
71
+ * tests inject a fixed value for determinism.
72
+ */
73
+ export function rollupCycleTimes(records, now) {
74
+ const leadTimes = [];
75
+ const phaseBuckets = {};
76
+ for (const rec of records) {
77
+ // Filter + parse + sort defensively. Do not mutate the caller's array.
78
+ const valid = rec.transitions
79
+ .map((t) => {
80
+ const ms = parseAt(t.at);
81
+ return ms === null ? null : { t, ms };
82
+ })
83
+ .filter((x) => x !== null)
84
+ .sort((a, b) => a.ms - b.ms);
85
+ if (valid.length === 0)
86
+ continue;
87
+ // Lead-time: only when the issue has >= 2 valid transitions.
88
+ if (valid.length >= 2) {
89
+ const firstMs = valid[0].ms;
90
+ const lastMs = valid[valid.length - 1].ms;
91
+ const hours = (lastMs - firstMs) / MS_PER_HOUR;
92
+ if (Number.isFinite(hours) && hours >= 0) {
93
+ leadTimes.push(hours);
94
+ }
95
+ }
96
+ // Per-phase dwell from consecutive intervals.
97
+ for (let i = 0; i < valid.length - 1; i++) {
98
+ const fromState = valid[i].t.from;
99
+ const dwellHours = (valid[i + 1].ms - valid[i].ms) / MS_PER_HOUR;
100
+ if (!Number.isFinite(dwellHours) || dwellHours < 0)
101
+ continue;
102
+ (phaseBuckets[fromState] ??= []).push(dwellHours);
103
+ }
104
+ // Terminal-state dwell: from the last transition's `to` state until
105
+ // either `closedAt` (if present) or `now` (if the issue is open).
106
+ // If the issue is open AND `closedAt` is missing, the terminal
107
+ // dwell is open-ended and excluded — matches the Phase 2 spec.
108
+ const last = valid[valid.length - 1];
109
+ const terminalState = last.t.to;
110
+ let upperMs = null;
111
+ if (rec.closedAt) {
112
+ const cMs = parseAt(rec.closedAt);
113
+ if (cMs !== null)
114
+ upperMs = cMs;
115
+ }
116
+ else {
117
+ // Open issue: skip open-ended terminal dwell rather than
118
+ // synthesising a value from `now`. (Spec: open-ended terminal
119
+ // dwell is excluded when `closedAt` is missing AND the issue is
120
+ // open.)
121
+ upperMs = null;
122
+ }
123
+ if (upperMs !== null) {
124
+ const dwellHours = (upperMs - last.ms) / MS_PER_HOUR;
125
+ if (Number.isFinite(dwellHours) && dwellHours >= 0) {
126
+ (phaseBuckets[terminalState] ??= []).push(dwellHours);
127
+ }
128
+ }
129
+ }
130
+ // ----- assemble percentile output -----------------------------------------
131
+ const perPhaseDwellHours = {};
132
+ for (const [phase, samples] of Object.entries(phaseBuckets)) {
133
+ if (samples.length === 0)
134
+ continue;
135
+ const sorted = [...samples].sort((a, b) => a - b);
136
+ perPhaseDwellHours[phase] = {
137
+ p50: percentile(sorted, 0.5),
138
+ p90: percentile(sorted, 0.9),
139
+ n: sorted.length,
140
+ };
141
+ }
142
+ let leadTimeP50Hours = null;
143
+ let leadTimeP90Hours = null;
144
+ if (leadTimes.length > 0) {
145
+ const sorted = [...leadTimes].sort((a, b) => a - b);
146
+ leadTimeP50Hours = percentile(sorted, 0.5);
147
+ leadTimeP90Hours = percentile(sorted, 0.9);
148
+ }
149
+ // Reference `now` for callers that want to bound things in the future.
150
+ // The current spec excludes open-ended terminal dwell, but `now` is
151
+ // retained in the API surface for forward-compat. Touch it here so
152
+ // strict-mode TS does not flag it as unused.
153
+ void now;
154
+ return {
155
+ leadTimeP50Hours,
156
+ leadTimeP90Hours,
157
+ perPhaseDwellHours,
158
+ sampleSize: leadTimes.length,
159
+ };
160
+ }
161
+ //# sourceMappingURL=cycle-times.js.map
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Reusable helper for fetching project items as DashboardItem[].
3
+ *
4
+ * Owns the GraphQL query (`DASHBOARD_ITEMS_QUERY`), the raw item shape
5
+ * (`RawDashboardItem`), and the raw→DashboardItem conversion
6
+ * (`toDashboardItems`). Previously these lived inline in
7
+ * `tools/dashboard-tools.ts`; centralising them lets
8
+ * `ralph_hero__capture_snapshot` and any future tool reuse the same
9
+ * fetch path without duplicating the GraphQL or conversion logic.
10
+ *
11
+ * Behaviour mirrors the inline fetch loop the dashboard tool used to
12
+ * run:
13
+ * - Resolve target project numbers (explicit arg →
14
+ * projectNumbers config → projectNumber config).
15
+ * - For each project: ensure field cache, look up project ID, fetch
16
+ * project title (best-effort), paginate the items connection,
17
+ * convert to DashboardItems tagged with project metadata.
18
+ * - Skip a project on field-cache failure or missing project ID;
19
+ * record a human-readable warning so the caller can surface it.
20
+ */
21
+ import { ensureFieldCache } from "./helpers.js";
22
+ import { paginateConnection } from "./pagination.js";
23
+ import { resolveProjectNumbers, resolveProjectOwner } from "../types.js";
24
+ function getFieldValue(item, fieldName) {
25
+ const fv = item.fieldValues.nodes.find((n) => n.field?.name === fieldName &&
26
+ n.__typename === "ProjectV2ItemFieldSingleSelectValue");
27
+ return fv?.name ?? null;
28
+ }
29
+ /**
30
+ * Convert raw GraphQL project items to DashboardItem[].
31
+ * When projectNumber/projectTitle are provided, they are set on each item
32
+ * for multi-project dashboard support.
33
+ */
34
+ export function toDashboardItems(raw, projectNumber, projectTitle) {
35
+ const items = [];
36
+ for (const r of raw) {
37
+ // Only include issues (not PRs or drafts)
38
+ if (!r.content || r.content.__typename !== "Issue")
39
+ continue;
40
+ if (r.content.number === undefined)
41
+ continue;
42
+ // Extract iteration value (if any)
43
+ const iterFv = r.fieldValues.nodes.find((n) => n.__typename === "ProjectV2ItemFieldIterationValue");
44
+ items.push({
45
+ number: r.content.number,
46
+ title: r.content.title ?? "(untitled)",
47
+ updatedAt: r.content.updatedAt ?? new Date(0).toISOString(),
48
+ closedAt: r.content.closedAt ?? null,
49
+ workflowState: getFieldValue(r, "Workflow State"),
50
+ priority: getFieldValue(r, "Priority"),
51
+ estimate: getFieldValue(r, "Estimate"),
52
+ assignees: r.content.assignees?.nodes?.map((a) => a.login) ?? [],
53
+ subIssueCount: r.content.subIssues?.totalCount ?? 0,
54
+ blockedBy: r.content.trackedIssues?.nodes?.map((n) => ({
55
+ number: n.number,
56
+ workflowState: n.state === "CLOSED" ? "Done" : null,
57
+ })) ?? [],
58
+ parentNumber: r.content.trackedInIssues?.nodes?.[0]?.number ?? null,
59
+ parentState: r.content.trackedInIssues?.nodes?.[0]?.state ?? null,
60
+ ...(projectNumber !== undefined ? { projectNumber } : {}),
61
+ ...(projectTitle !== undefined ? { projectTitle } : {}),
62
+ ...(r.content.repository ? { repository: r.content.repository.nameWithOwner } : {}),
63
+ ...(iterFv?.iterationId ? {
64
+ iterationId: iterFv.iterationId,
65
+ iterationTitle: iterFv.title ?? undefined,
66
+ iterationStartDate: iterFv.startDate ?? undefined,
67
+ iterationDuration: iterFv.duration ?? undefined,
68
+ } : {}),
69
+ });
70
+ }
71
+ return items;
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // GraphQL query for dashboard items
75
+ // ---------------------------------------------------------------------------
76
+ export const DASHBOARD_ITEMS_QUERY = `query($projectId: ID!, $cursor: String, $first: Int!) {
77
+ node(id: $projectId) {
78
+ ... on ProjectV2 {
79
+ items(first: $first, after: $cursor) {
80
+ totalCount
81
+ pageInfo { hasNextPage endCursor }
82
+ nodes {
83
+ id
84
+ type
85
+ content {
86
+ ... on Issue {
87
+ __typename
88
+ number
89
+ title
90
+ state
91
+ updatedAt
92
+ closedAt
93
+ assignees(first: 5) { nodes { login } }
94
+ repository { nameWithOwner name }
95
+ subIssues { totalCount }
96
+ trackedIssues(first: 10) { nodes { number state } }
97
+ trackedInIssues(first: 3) { nodes { number state closedAt } }
98
+ }
99
+ ... on PullRequest {
100
+ __typename
101
+ number
102
+ title
103
+ state
104
+ }
105
+ ... on DraftIssue {
106
+ __typename
107
+ title
108
+ }
109
+ }
110
+ fieldValues(first: 20) {
111
+ nodes {
112
+ ... on ProjectV2ItemFieldSingleSelectValue {
113
+ __typename
114
+ name
115
+ field { ... on ProjectV2FieldCommon { name } }
116
+ }
117
+ ... on ProjectV2ItemFieldIterationValue {
118
+ __typename
119
+ iterationId
120
+ title
121
+ startDate
122
+ duration
123
+ field { ... on ProjectV2FieldCommon { name } }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }`;
132
+ /**
133
+ * Resolve project numbers to fetch from arg + client config.
134
+ *
135
+ * Priority:
136
+ * 1. Explicit `projectNumber` argument (single project).
137
+ * 2. `client.config.projectNumbers` (multi-project).
138
+ * 3. `client.config.projectNumber` (single project).
139
+ */
140
+ function resolveTargetProjectNumbers(client, projectNumber) {
141
+ if (projectNumber !== undefined)
142
+ return [projectNumber];
143
+ return resolveProjectNumbers(client.config);
144
+ }
145
+ /**
146
+ * Fetch dashboard items for one or more projects, returning a flat
147
+ * `DashboardItem[]` plus any per-project fetch warnings.
148
+ *
149
+ * The owner is resolved from `client.config` via `resolveProjectOwner`.
150
+ *
151
+ * Throws if no owner can be resolved or if no project numbers are
152
+ * configured. Per-project failures (missing project, field-cache
153
+ * failure, missing project ID) are non-fatal — those projects are
154
+ * skipped with a warning so a partial dashboard is still produced.
155
+ */
156
+ export async function fetchDashboardItems(client, fieldCache, projectNumber) {
157
+ const owner = resolveProjectOwner(client.config);
158
+ if (!owner) {
159
+ throw new Error("owner is required (set RALPH_GH_OWNER)");
160
+ }
161
+ const projectNumbers = resolveTargetProjectNumbers(client, projectNumber);
162
+ if (projectNumbers.length === 0) {
163
+ throw new Error("No project numbers configured. Set RALPH_GH_PROJECT_NUMBER or RALPH_GH_PROJECT_NUMBERS.");
164
+ }
165
+ const items = [];
166
+ const warnings = [];
167
+ for (const pn of projectNumbers) {
168
+ try {
169
+ await ensureFieldCache(client, fieldCache, owner, pn);
170
+ }
171
+ catch (e) {
172
+ warnings.push(`Project #${pn}: ${e instanceof Error ? e.message : String(e)}, skipping`);
173
+ continue;
174
+ }
175
+ const projectId = fieldCache.getProjectId(pn);
176
+ if (!projectId) {
177
+ warnings.push(`Project #${pn}: could not resolve project ID, skipping`);
178
+ continue;
179
+ }
180
+ // Fetch project title (non-fatal on failure)
181
+ let projectTitle;
182
+ try {
183
+ const titleResult = await client.projectQuery(`query($projectId: ID!) { node(id: $projectId) { ... on ProjectV2 { title } } }`, { projectId });
184
+ projectTitle = titleResult.node?.title;
185
+ }
186
+ catch {
187
+ // Non-fatal — proceed without title.
188
+ }
189
+ const result = await paginateConnection((q, v) => client.projectQuery(q, v), DASHBOARD_ITEMS_QUERY, { projectId, first: 100 }, "node.items", { maxItems: 500 });
190
+ items.push(...toDashboardItems(result.nodes, pn, projectTitle));
191
+ }
192
+ return { items, warnings };
193
+ }
194
+ //# sourceMappingURL=dashboard-fetch.js.map
@@ -5,6 +5,7 @@
5
5
  * I/O (GraphQL fetching) lives in tools/hygiene-tools.ts.
6
6
  */
7
7
  import { TERMINAL_STATES } from "./workflow-states.js";
8
+ import { groupDashboardItemsByRepo } from "./dashboard.js";
8
9
  export const DEFAULT_HYGIENE_CONFIG = {
9
10
  archiveDays: 14,
10
11
  staleDays: 7,
@@ -24,6 +25,7 @@ function toHygieneItem(item, now) {
24
25
  title: item.title,
25
26
  workflowState: item.workflowState,
26
27
  ageDays: Math.round(ageDays(item.updatedAt, now) * 10) / 10,
28
+ ...(item.repository ? { repository: item.repository } : {}),
27
29
  };
28
30
  }
29
31
  // ---------------------------------------------------------------------------
@@ -190,6 +192,45 @@ export function findDuplicateCandidates(items, now, threshold) {
190
192
  // ---------------------------------------------------------------------------
191
193
  // Orchestrator
192
194
  // ---------------------------------------------------------------------------
195
+ /**
196
+ * Build a per-repo hygiene breakdown. Runs each section function over the
197
+ * supplied items (a single repo's subset of the merged item set).
198
+ *
199
+ * Does NOT recursively emit `repoBreakdowns` — per-repo breakdowns are flat.
200
+ */
201
+ function buildRepoBreakdown(repoName, items, config, now) {
202
+ const archiveCandidates = findArchiveCandidates(items, now, config.archiveDays);
203
+ const staleItems = findStaleItems(items, now, config.staleDays);
204
+ const orphanedItems = findOrphanedItems(items, now, config.orphanDays);
205
+ const fieldGaps = findFieldGaps(items, now);
206
+ const wipViolations = findWipViolations(items, now, config.wipLimits);
207
+ const duplicateCandidates = findDuplicateCandidates(items, now, config.similarityThreshold);
208
+ const nonTerminal = items.filter((item) => {
209
+ const ws = item.workflowState;
210
+ return !ws || !TERMINAL_STATES.includes(ws);
211
+ });
212
+ const withBothFields = nonTerminal.filter((item) => item.estimate !== null && item.priority !== null);
213
+ const fieldCoveragePercent = nonTerminal.length > 0
214
+ ? Math.round((withBothFields.length / nonTerminal.length) * 100)
215
+ : 100;
216
+ return {
217
+ repoName,
218
+ archiveCandidates,
219
+ staleItems,
220
+ orphanedItems,
221
+ fieldGaps,
222
+ wipViolations,
223
+ duplicateCandidates,
224
+ summary: {
225
+ archiveCandidateCount: archiveCandidates.length,
226
+ staleCount: staleItems.length,
227
+ orphanCount: orphanedItems.length,
228
+ fieldCoveragePercent,
229
+ wipViolationCount: wipViolations.length,
230
+ duplicateCandidateCount: duplicateCandidates.length,
231
+ },
232
+ };
233
+ }
193
234
  /**
194
235
  * Build a complete hygiene report from project items.
195
236
  */
@@ -209,6 +250,16 @@ export function buildHygieneReport(items, config = DEFAULT_HYGIENE_CONFIG, now =
209
250
  const fieldCoveragePercent = nonTerminal.length > 0
210
251
  ? Math.round((withBothFields.length / nonTerminal.length) * 100)
211
252
  : 100;
253
+ // Per-repo breakdown (only emitted when items span 2+ repos, mirroring
254
+ // buildDashboard's repoBreakdowns threshold at lib/dashboard.ts:781).
255
+ const repoGroups = groupDashboardItemsByRepo(items);
256
+ let repoBreakdowns;
257
+ if (Object.keys(repoGroups).length >= 2) {
258
+ repoBreakdowns = {};
259
+ for (const [repoName, repoItems] of Object.entries(repoGroups)) {
260
+ repoBreakdowns[repoName] = buildRepoBreakdown(repoName, repoItems, config, now);
261
+ }
262
+ }
212
263
  return {
213
264
  generatedAt: new Date(now).toISOString(),
214
265
  totalItems: items.length,
@@ -226,6 +277,7 @@ export function buildHygieneReport(items, config = DEFAULT_HYGIENE_CONFIG, now =
226
277
  wipViolationCount: wipViolations.length,
227
278
  duplicateCandidateCount: duplicateCandidates.length,
228
279
  },
280
+ ...(repoBreakdowns ? { repoBreakdowns } : {}),
229
281
  };
230
282
  }
231
283
  // ---------------------------------------------------------------------------
@@ -331,6 +383,101 @@ export function formatHygieneMarkdown(report) {
331
383
  }
332
384
  lines.push("");
333
385
  }
386
+ // Per-repository breakdown (only for multi-repo). Mirrors
387
+ // formatMarkdown's per-repo rendering at lib/dashboard.ts:948+.
388
+ if (report.repoBreakdowns &&
389
+ Object.keys(report.repoBreakdowns).length >= 2) {
390
+ lines.push("## Per-Repository Breakdown");
391
+ lines.push("");
392
+ const sortedRepos = Object.values(report.repoBreakdowns).sort((a, b) => a.repoName.localeCompare(b.repoName));
393
+ for (const repo of sortedRepos) {
394
+ lines.push(`### ${repo.repoName}`);
395
+ lines.push("");
396
+ let renderedAny = false;
397
+ if (repo.archiveCandidates.length > 0) {
398
+ lines.push("#### Archive Candidates");
399
+ lines.push("| Issue | Title | State | Age |");
400
+ lines.push("|-------|-------|-------|-----|");
401
+ for (const item of repo.archiveCandidates) {
402
+ lines.push(formatItemRow(item));
403
+ }
404
+ lines.push("");
405
+ renderedAny = true;
406
+ }
407
+ if (repo.staleItems.length > 0) {
408
+ lines.push("#### Stale Items");
409
+ lines.push("| Issue | Title | State | Age |");
410
+ lines.push("|-------|-------|-------|-----|");
411
+ for (const item of repo.staleItems) {
412
+ lines.push(formatItemRow(item));
413
+ }
414
+ lines.push("");
415
+ renderedAny = true;
416
+ }
417
+ if (repo.orphanedItems.length > 0) {
418
+ lines.push("#### Orphaned Items");
419
+ lines.push("| Issue | Title | State | Age |");
420
+ lines.push("|-------|-------|-------|-----|");
421
+ for (const item of repo.orphanedItems) {
422
+ lines.push(formatItemRow(item));
423
+ }
424
+ lines.push("");
425
+ renderedAny = true;
426
+ }
427
+ const repoTotalGaps = repo.fieldGaps.missingEstimate.length +
428
+ repo.fieldGaps.missingPriority.length;
429
+ if (repoTotalGaps > 0) {
430
+ lines.push("#### Field Gaps");
431
+ if (repo.fieldGaps.missingEstimate.length > 0) {
432
+ lines.push("##### Missing Estimate");
433
+ lines.push("| Issue | Title | State | Age |");
434
+ lines.push("|-------|-------|-------|-----|");
435
+ for (const item of repo.fieldGaps.missingEstimate) {
436
+ lines.push(formatItemRow(item));
437
+ }
438
+ lines.push("");
439
+ }
440
+ if (repo.fieldGaps.missingPriority.length > 0) {
441
+ lines.push("##### Missing Priority");
442
+ lines.push("| Issue | Title | State | Age |");
443
+ lines.push("|-------|-------|-------|-----|");
444
+ for (const item of repo.fieldGaps.missingPriority) {
445
+ lines.push(formatItemRow(item));
446
+ }
447
+ lines.push("");
448
+ }
449
+ renderedAny = true;
450
+ }
451
+ if (repo.wipViolations.length > 0) {
452
+ lines.push("#### WIP Violations");
453
+ for (const v of repo.wipViolations) {
454
+ lines.push(`##### ${v.state}: ${v.count} items (limit: ${v.limit})`);
455
+ lines.push("| Issue | Title | State | Age |");
456
+ lines.push("|-------|-------|-------|-----|");
457
+ for (const item of v.items) {
458
+ lines.push(formatItemRow(item));
459
+ }
460
+ lines.push("");
461
+ }
462
+ renderedAny = true;
463
+ }
464
+ if (repo.duplicateCandidates.length > 0) {
465
+ lines.push("#### Duplicate Candidates");
466
+ lines.push("| Issue A | Title A | Issue B | Title B | Similarity |");
467
+ lines.push("|---------|---------|---------|---------|------------|");
468
+ for (const dup of repo.duplicateCandidates) {
469
+ const [a, b] = dup.items;
470
+ lines.push(`| #${a.number} | ${a.title} | #${b.number} | ${b.title} | ${dup.similarity.toFixed(2)} |`);
471
+ }
472
+ lines.push("");
473
+ renderedAny = true;
474
+ }
475
+ if (!renderedAny) {
476
+ lines.push("_No hygiene issues_");
477
+ lines.push("");
478
+ }
479
+ }
480
+ }
334
481
  return lines.join("\n");
335
482
  }
336
483
  //# sourceMappingURL=hygiene.js.map
@@ -18,20 +18,6 @@ function getNestedValue(obj, path) {
18
18
  }
19
19
  return current;
20
20
  }
21
- /**
22
- * Set a nested value on an object using a dot-separated path.
23
- */
24
- function setNestedValue(obj, path, value) {
25
- const parts = path.split(".");
26
- let current = obj;
27
- for (let i = 0; i < parts.length - 1; i++) {
28
- if (current[parts[i]] == null || typeof current[parts[i]] !== "object") {
29
- current[parts[i]] = {};
30
- }
31
- current = current[parts[i]];
32
- }
33
- current[parts[parts.length - 1]] = value;
34
- }
35
21
  /**
36
22
  * Paginate a GraphQL connection query.
37
23
  *