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 +3 -0
- package/dist/lib/cycle-times.js +161 -0
- package/dist/lib/dashboard-fetch.js +194 -0
- package/dist/lib/hygiene.js +147 -0
- package/dist/lib/pagination.js +0 -14
- package/dist/lib/snapshots.js +255 -0
- package/dist/lib/trends.js +157 -0
- package/dist/tools/dashboard-tools.js +19 -186
- package/dist/tools/hygiene-tools.js +76 -19
- package/dist/tools/trends-tools.js +184 -0
- package/package.json +1 -1
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
|
package/dist/lib/hygiene.js
CHANGED
|
@@ -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
|
package/dist/lib/pagination.js
CHANGED
|
@@ -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
|
*
|