standup-mr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/chunk-SKCJT2AY.js +551 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +105 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.js +37 -0
- package/dist/mcp/server.js +468 -0
- package/mcp/README.md +33 -0
- package/package.json +54 -0
- package/skill/SKILL.md +102 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// mcp/server.ts
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
|
|
6
|
+
// src/config/config.ts
|
|
7
|
+
import { execFileSync } from "child_process";
|
|
8
|
+
|
|
9
|
+
// src/config/config.constants.ts
|
|
10
|
+
var GLAB_TIMEOUT_MS = 15e3;
|
|
11
|
+
|
|
12
|
+
// src/config/config.ts
|
|
13
|
+
var ConfigError = class extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "ConfigError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
function resolveHost(cliHost, envHost, glabHostList) {
|
|
20
|
+
if (cliHost) return cliHost;
|
|
21
|
+
if (envHost) return envHost;
|
|
22
|
+
const hosts = glabHostList ?? [];
|
|
23
|
+
if (hosts.length === 1) return hosts[0];
|
|
24
|
+
if (hosts.length === 0) {
|
|
25
|
+
throw new ConfigError(
|
|
26
|
+
"No GitLab host configured. Pass --host, set GITLAB_HOST, or log in with `glab auth login`."
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
throw new ConfigError(
|
|
30
|
+
`Multiple GitLab hosts found (${[...hosts].sort().join(", ")}). Pick one with --host or GITLAB_HOST.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
function resolveToken(host, cliToken, envToken, glabLookup) {
|
|
34
|
+
if (cliToken) return cliToken;
|
|
35
|
+
if (envToken) return envToken;
|
|
36
|
+
if (glabLookup) {
|
|
37
|
+
const token = glabLookup(host);
|
|
38
|
+
if (token) return token;
|
|
39
|
+
}
|
|
40
|
+
throw new ConfigError(
|
|
41
|
+
`No token for ${host}. Pass --token, set GITLAB_TOKEN, or run \`glab auth login --hostname ${host}\`.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
function glab(args) {
|
|
45
|
+
try {
|
|
46
|
+
return execFileSync("glab", args, {
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
timeout: GLAB_TIMEOUT_MS,
|
|
49
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
50
|
+
}).trim();
|
|
51
|
+
} catch {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function parseGlabHosts(statusOutput) {
|
|
56
|
+
const hosts = [...statusOutput.matchAll(/Logged in to (\S+)/g)].map((match) => match[1]);
|
|
57
|
+
return [...new Set(hosts)].sort();
|
|
58
|
+
}
|
|
59
|
+
function glabStatus() {
|
|
60
|
+
try {
|
|
61
|
+
return execFileSync("glab", ["auth", "status"], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
timeout: GLAB_TIMEOUT_MS,
|
|
64
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const result = error;
|
|
68
|
+
return `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function glabHosts() {
|
|
72
|
+
return parseGlabHosts(glabStatus());
|
|
73
|
+
}
|
|
74
|
+
function glabToken(host) {
|
|
75
|
+
return glab(["config", "get", "token", "--host", host]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/buckets/buckets.constants.ts
|
|
79
|
+
var STALE_DAYS = 14;
|
|
80
|
+
|
|
81
|
+
// src/dates/dates.constants.ts
|
|
82
|
+
var DAYS = {
|
|
83
|
+
en: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
|
|
84
|
+
tr: ["Pazar", "Pazartesi", "Sal\u0131", "\xC7ar\u015Famba", "Per\u015Fembe", "Cuma", "Cumartesi"]
|
|
85
|
+
};
|
|
86
|
+
var MONTHS = {
|
|
87
|
+
en: [
|
|
88
|
+
"January",
|
|
89
|
+
"February",
|
|
90
|
+
"March",
|
|
91
|
+
"April",
|
|
92
|
+
"May",
|
|
93
|
+
"June",
|
|
94
|
+
"July",
|
|
95
|
+
"August",
|
|
96
|
+
"September",
|
|
97
|
+
"October",
|
|
98
|
+
"November",
|
|
99
|
+
"December"
|
|
100
|
+
],
|
|
101
|
+
tr: [
|
|
102
|
+
"Ocak",
|
|
103
|
+
"\u015Eubat",
|
|
104
|
+
"Mart",
|
|
105
|
+
"Nisan",
|
|
106
|
+
"May\u0131s",
|
|
107
|
+
"Haziran",
|
|
108
|
+
"Temmuz",
|
|
109
|
+
"A\u011Fustos",
|
|
110
|
+
"Eyl\xFCl",
|
|
111
|
+
"Ekim",
|
|
112
|
+
"Kas\u0131m",
|
|
113
|
+
"Aral\u0131k"
|
|
114
|
+
]
|
|
115
|
+
};
|
|
116
|
+
var MS_PER_DAY = 864e5;
|
|
117
|
+
|
|
118
|
+
// src/dates/dates.ts
|
|
119
|
+
function isoDay(day) {
|
|
120
|
+
const year = day.getFullYear();
|
|
121
|
+
const month = String(day.getMonth() + 1).padStart(2, "0");
|
|
122
|
+
const date = String(day.getDate()).padStart(2, "0");
|
|
123
|
+
return `${year}-${month}-${date}`;
|
|
124
|
+
}
|
|
125
|
+
function label(day, lang = "en") {
|
|
126
|
+
const key = DAYS[lang] ? lang : "en";
|
|
127
|
+
const weekday = DAYS[key][day.getDay()];
|
|
128
|
+
const month = MONTHS[key][day.getMonth()];
|
|
129
|
+
const date = day.getDate();
|
|
130
|
+
return key === "tr" ? `${date} ${month} ${weekday}` : `${weekday}, ${date} ${month}`;
|
|
131
|
+
}
|
|
132
|
+
function previousActiveDay(eventDates, today) {
|
|
133
|
+
const cutoff = isoDay(today);
|
|
134
|
+
const past = [...eventDates].filter((d) => d < cutoff).sort();
|
|
135
|
+
const latest = past[past.length - 1];
|
|
136
|
+
if (!latest) return { date: null, gapDays: null };
|
|
137
|
+
const gapDays = Math.round(
|
|
138
|
+
(Date.parse(`${cutoff}T00:00:00Z`) - Date.parse(`${latest}T00:00:00Z`)) / MS_PER_DAY
|
|
139
|
+
);
|
|
140
|
+
return { date: latest, gapDays };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/buckets/buckets.ts
|
|
144
|
+
function classify(mr, today, staleDays = STALE_DAYS) {
|
|
145
|
+
if (mr.draft) return "draft";
|
|
146
|
+
if (mr.pipeline === "failed" || mr.unresolved > 0) return "blocked";
|
|
147
|
+
const age = Math.round(
|
|
148
|
+
(Date.parse(`${isoDay(today)}T00:00:00Z`) - Date.parse(`${mr.updated}T00:00:00Z`)) / MS_PER_DAY
|
|
149
|
+
);
|
|
150
|
+
return age >= staleDays ? "stale" : "ready";
|
|
151
|
+
}
|
|
152
|
+
function markMissingPipelines(mrs) {
|
|
153
|
+
const withCi = new Set(mrs.filter((mr) => mr.pipeline).map((mr) => mr.project));
|
|
154
|
+
for (const mr of mrs) {
|
|
155
|
+
mr.pipelineMissing = mr.pipeline === null && withCi.has(mr.project);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/trace/trace.constants.ts
|
|
160
|
+
var ANSI = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
|
161
|
+
var SECTION = /section_(start|end):\d+:\S*/g;
|
|
162
|
+
var SIGNAL = /(\berror\b|Error:|fatal:|npm ERR!|\bfailed\b)/i;
|
|
163
|
+
var NOISE = /^(Cleaning up|Job failed: exit status|ERROR: Job failed|Uploading artifacts|Job succeeded)/i;
|
|
164
|
+
var MAX_LINE = 200;
|
|
165
|
+
|
|
166
|
+
// src/trace/trace.ts
|
|
167
|
+
function extractErrors(rawTrace, limit = 8) {
|
|
168
|
+
if (!rawTrace) return [];
|
|
169
|
+
const cleaned = rawTrace.replace(ANSI, "").replace(SECTION, "");
|
|
170
|
+
const seen = /* @__PURE__ */ new Set();
|
|
171
|
+
const hits = [];
|
|
172
|
+
for (const rawLine of cleaned.split("\n")) {
|
|
173
|
+
const line = rawLine.trim();
|
|
174
|
+
if (!line || NOISE.test(line) || !SIGNAL.test(line)) continue;
|
|
175
|
+
const clipped = line.slice(0, MAX_LINE);
|
|
176
|
+
if (seen.has(clipped)) continue;
|
|
177
|
+
seen.add(clipped);
|
|
178
|
+
hits.push(clipped);
|
|
179
|
+
}
|
|
180
|
+
return hits.slice(-limit);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/providers/gitlab/gitlab.constants.ts
|
|
184
|
+
var PAGE_SIZE = 100;
|
|
185
|
+
var FRESH_REVIEW_DAYS = 7;
|
|
186
|
+
|
|
187
|
+
// src/providers/gitlab/gitlab.ts
|
|
188
|
+
var GitLabProvider = class {
|
|
189
|
+
host;
|
|
190
|
+
api;
|
|
191
|
+
token;
|
|
192
|
+
fetchImpl;
|
|
193
|
+
constructor(host, token, fetchImpl = fetch) {
|
|
194
|
+
this.host = host;
|
|
195
|
+
this.token = token;
|
|
196
|
+
this.api = `https://${host}/api/v4`;
|
|
197
|
+
this.fetchImpl = fetchImpl;
|
|
198
|
+
}
|
|
199
|
+
url(path, params) {
|
|
200
|
+
const base = `${this.api}/${path}`;
|
|
201
|
+
if (!params || Object.keys(params).length === 0) return base;
|
|
202
|
+
const query = new URLSearchParams();
|
|
203
|
+
for (const [key, value] of Object.entries(params)) {
|
|
204
|
+
query.set(key, String(value));
|
|
205
|
+
}
|
|
206
|
+
return `${base}?${query.toString()}`;
|
|
207
|
+
}
|
|
208
|
+
async getJson(path, params) {
|
|
209
|
+
try {
|
|
210
|
+
const response = await this.fetchImpl(this.url(path, params), {
|
|
211
|
+
headers: { "PRIVATE-TOKEN": this.token }
|
|
212
|
+
});
|
|
213
|
+
if (!response.ok) return null;
|
|
214
|
+
return await response.json();
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async getText(path) {
|
|
220
|
+
try {
|
|
221
|
+
const response = await this.fetchImpl(this.url(path), {
|
|
222
|
+
headers: { "PRIVATE-TOKEN": this.token }
|
|
223
|
+
});
|
|
224
|
+
if (!response.ok) return "";
|
|
225
|
+
return await response.text();
|
|
226
|
+
} catch {
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async getPaged(path, params = {}, cap = 5) {
|
|
231
|
+
const rows = [];
|
|
232
|
+
for (let page = 1; page <= cap; page += 1) {
|
|
233
|
+
const chunk = await this.getJson(path, {
|
|
234
|
+
...params,
|
|
235
|
+
per_page: PAGE_SIZE,
|
|
236
|
+
page
|
|
237
|
+
});
|
|
238
|
+
if (!chunk || chunk.length === 0) break;
|
|
239
|
+
rows.push(...chunk);
|
|
240
|
+
if (chunk.length < PAGE_SIZE) break;
|
|
241
|
+
}
|
|
242
|
+
return rows;
|
|
243
|
+
}
|
|
244
|
+
async getIdentity() {
|
|
245
|
+
const me = await this.getJson("user");
|
|
246
|
+
if (!me) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Could not reach ${this.host}. Check the host, the token, and network access.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return { id: me.id, username: me.username };
|
|
252
|
+
}
|
|
253
|
+
async projectPath(projectId) {
|
|
254
|
+
const project = await this.getJson(
|
|
255
|
+
`projects/${projectId}`
|
|
256
|
+
);
|
|
257
|
+
return project?.path_with_namespace ?? String(projectId);
|
|
258
|
+
}
|
|
259
|
+
async getEvents(since) {
|
|
260
|
+
const raw = await this.getPaged("events", {
|
|
261
|
+
after: isoDay(since)
|
|
262
|
+
});
|
|
263
|
+
const ids = [...new Set(raw.map((e) => e.project_id).filter(Boolean))];
|
|
264
|
+
const paths = new Map(
|
|
265
|
+
await Promise.all(
|
|
266
|
+
ids.map(async (id) => [id, await this.projectPath(id)])
|
|
267
|
+
)
|
|
268
|
+
);
|
|
269
|
+
return raw.map((event) => {
|
|
270
|
+
const push = event.push_data ?? {};
|
|
271
|
+
return {
|
|
272
|
+
at: String(event.created_at).slice(0, 16),
|
|
273
|
+
action: event.action_name,
|
|
274
|
+
project: paths.get(event.project_id) ?? "",
|
|
275
|
+
targetType: event.target_type ?? "",
|
|
276
|
+
title: event.target_title ?? "",
|
|
277
|
+
branch: push.ref ?? "",
|
|
278
|
+
commits: push.commit_count ?? 0,
|
|
279
|
+
commitTitle: push.commit_title ?? ""
|
|
280
|
+
};
|
|
281
|
+
}).sort((a, b) => a.at.localeCompare(b.at));
|
|
282
|
+
}
|
|
283
|
+
async countUnresolved(projectId, iid) {
|
|
284
|
+
const discussions = await this.getJson(
|
|
285
|
+
`projects/${projectId}/merge_requests/${iid}/discussions`,
|
|
286
|
+
{ per_page: PAGE_SIZE }
|
|
287
|
+
);
|
|
288
|
+
if (!discussions) return 0;
|
|
289
|
+
return discussions.filter((discussion) => {
|
|
290
|
+
const notes = (discussion.notes ?? []).filter((n) => !n.system);
|
|
291
|
+
return notes.length > 0 && notes.some((n) => n.resolvable && !n.resolved);
|
|
292
|
+
}).length;
|
|
293
|
+
}
|
|
294
|
+
async shapeMr(mr) {
|
|
295
|
+
const projectId = mr.project_id;
|
|
296
|
+
const iid = mr.iid;
|
|
297
|
+
const [pipelines, unresolved] = await Promise.all([
|
|
298
|
+
this.getJson(
|
|
299
|
+
`projects/${projectId}/merge_requests/${iid}/pipelines`,
|
|
300
|
+
{ per_page: 1 }
|
|
301
|
+
),
|
|
302
|
+
this.countUnresolved(projectId, iid)
|
|
303
|
+
]);
|
|
304
|
+
const latest = pipelines?.[0];
|
|
305
|
+
return {
|
|
306
|
+
project: String(mr.references.full).split("!")[0],
|
|
307
|
+
projectId,
|
|
308
|
+
iid,
|
|
309
|
+
title: mr.title,
|
|
310
|
+
draft: mr.draft,
|
|
311
|
+
branch: mr.source_branch,
|
|
312
|
+
target: mr.target_branch,
|
|
313
|
+
updated: String(mr.updated_at).slice(0, 10),
|
|
314
|
+
url: mr.web_url,
|
|
315
|
+
mergeStatus: mr.detailed_merge_status ?? null,
|
|
316
|
+
pipeline: latest?.status ?? null,
|
|
317
|
+
pipelineId: latest?.id ?? null,
|
|
318
|
+
unresolved,
|
|
319
|
+
pipelineMissing: false,
|
|
320
|
+
bucket: "ready"
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async getMyMrs(today) {
|
|
324
|
+
const raw = await this.getPaged("merge_requests", {
|
|
325
|
+
scope: "created_by_me",
|
|
326
|
+
state: "opened"
|
|
327
|
+
});
|
|
328
|
+
const rows = await Promise.all(raw.map((mr) => this.shapeMr(mr)));
|
|
329
|
+
markMissingPipelines(rows);
|
|
330
|
+
for (const row of rows) {
|
|
331
|
+
row.bucket = classify(row, today);
|
|
332
|
+
}
|
|
333
|
+
return rows;
|
|
334
|
+
}
|
|
335
|
+
async approvedByMe(projectId, iid, uid) {
|
|
336
|
+
const approvals = await this.getJson(`projects/${projectId}/merge_requests/${iid}/approvals`);
|
|
337
|
+
if (!approvals) return false;
|
|
338
|
+
return (approvals.approved_by ?? []).some((entry2) => entry2.user?.id === uid);
|
|
339
|
+
}
|
|
340
|
+
async getReviews(uid, today) {
|
|
341
|
+
const raw = await this.getPaged("merge_requests", {
|
|
342
|
+
scope: "all",
|
|
343
|
+
state: "opened",
|
|
344
|
+
reviewer_id: uid
|
|
345
|
+
});
|
|
346
|
+
const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS * MS_PER_DAY));
|
|
347
|
+
const rows = await Promise.all(
|
|
348
|
+
raw.map(async (mr) => ({
|
|
349
|
+
project: String(mr.references.full).split("!")[0],
|
|
350
|
+
iid: mr.iid,
|
|
351
|
+
title: mr.title,
|
|
352
|
+
author: mr.author.name,
|
|
353
|
+
updated: String(mr.updated_at).slice(0, 10),
|
|
354
|
+
draft: mr.draft,
|
|
355
|
+
url: mr.web_url,
|
|
356
|
+
fresh: String(mr.updated_at).slice(0, 10) >= cutoff,
|
|
357
|
+
approvedByMe: await this.approvedByMe(mr.project_id, mr.iid, uid)
|
|
358
|
+
}))
|
|
359
|
+
);
|
|
360
|
+
return rows.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
361
|
+
}
|
|
362
|
+
async getBlockers(mrs) {
|
|
363
|
+
const red = mrs.filter((mr) => mr.pipeline === "failed");
|
|
364
|
+
const diagnosed = await Promise.all(
|
|
365
|
+
red.map(async (mr) => {
|
|
366
|
+
const jobs = await this.getJson(
|
|
367
|
+
`projects/${mr.projectId}/pipelines/${mr.pipelineId}/jobs`,
|
|
368
|
+
{ per_page: PAGE_SIZE }
|
|
369
|
+
) ?? [];
|
|
370
|
+
const job = jobs.find((j) => j.status === "failed");
|
|
371
|
+
if (!job) return null;
|
|
372
|
+
const trace = await this.getText(
|
|
373
|
+
`projects/${mr.projectId}/jobs/${job.id}/trace`
|
|
374
|
+
);
|
|
375
|
+
return {
|
|
376
|
+
project: mr.project,
|
|
377
|
+
mr: mr.iid,
|
|
378
|
+
title: mr.title,
|
|
379
|
+
job: job.name,
|
|
380
|
+
stage: job.stage,
|
|
381
|
+
url: mr.url,
|
|
382
|
+
errors: extractErrors(trace)
|
|
383
|
+
};
|
|
384
|
+
})
|
|
385
|
+
);
|
|
386
|
+
return diagnosed.filter((row) => row !== null);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// src/report/report.constants.ts
|
|
391
|
+
var LOOKBACK_DAYS = 21;
|
|
392
|
+
|
|
393
|
+
// src/report/report.ts
|
|
394
|
+
async function buildReport(provider, today, lang = "en", lookbackDays = LOOKBACK_DAYS) {
|
|
395
|
+
const identity = await provider.getIdentity();
|
|
396
|
+
const since = new Date(today.getTime() - lookbackDays * MS_PER_DAY);
|
|
397
|
+
const events = await provider.getEvents(since);
|
|
398
|
+
const { date: previousDate, gapDays } = previousActiveDay(
|
|
399
|
+
new Set(events.map((e) => e.at.slice(0, 10))),
|
|
400
|
+
today
|
|
401
|
+
);
|
|
402
|
+
const previousEvents = previousDate ? events.filter((e) => e.at.slice(0, 10) === previousDate) : [];
|
|
403
|
+
const todayEvents = events.filter((e) => e.at.slice(0, 10) === isoDay(today));
|
|
404
|
+
const myMrs = await provider.getMyMrs(today);
|
|
405
|
+
const [reviews, blockers] = await Promise.all([
|
|
406
|
+
provider.getReviews(identity.id, today),
|
|
407
|
+
provider.getBlockers(myMrs)
|
|
408
|
+
]);
|
|
409
|
+
return {
|
|
410
|
+
user: identity.username,
|
|
411
|
+
today: { date: isoDay(today), label: label(today, lang) },
|
|
412
|
+
previous: {
|
|
413
|
+
date: previousDate,
|
|
414
|
+
label: previousDate ? label(/* @__PURE__ */ new Date(`${previousDate}T00:00:00`), lang) : null,
|
|
415
|
+
gapDays,
|
|
416
|
+
eventCount: previousEvents.length
|
|
417
|
+
},
|
|
418
|
+
previousEvents,
|
|
419
|
+
todayEvents,
|
|
420
|
+
myMrs,
|
|
421
|
+
reviews,
|
|
422
|
+
reviewPendingCount: reviews.filter((r) => !r.approvedByMe).length,
|
|
423
|
+
blockers
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// mcp/server.ts
|
|
428
|
+
function resolveProvider(options) {
|
|
429
|
+
if (options.provider) return options.provider;
|
|
430
|
+
const host = resolveHost(options.host, process.env.GITLAB_HOST, glabHosts());
|
|
431
|
+
const token = resolveToken(host, options.token, process.env.GITLAB_TOKEN, glabToken);
|
|
432
|
+
return new GitLabProvider(host, token);
|
|
433
|
+
}
|
|
434
|
+
async function collect(options = {}) {
|
|
435
|
+
return buildReport(resolveProvider(options), /* @__PURE__ */ new Date(), options.lang ?? "en");
|
|
436
|
+
}
|
|
437
|
+
async function main() {
|
|
438
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
439
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
440
|
+
const server = new McpServer({ name: "standup-mr", version: "0.1.0" });
|
|
441
|
+
server.tool(
|
|
442
|
+
"get_standup_data",
|
|
443
|
+
"Collect merge-request-based standup data from GitLab. Returns the previous working day activity, open merge requests bucketed by state (ready / blocked / draft / stale), pending reviews, and the error lines from any failed CI pipeline.",
|
|
444
|
+
{},
|
|
445
|
+
async () => {
|
|
446
|
+
const report = await collect();
|
|
447
|
+
return { content: [{ type: "text", text: JSON.stringify(report) }] };
|
|
448
|
+
}
|
|
449
|
+
);
|
|
450
|
+
await server.connect(new StdioServerTransport());
|
|
451
|
+
}
|
|
452
|
+
var entry = process.argv[1];
|
|
453
|
+
if (entry && import.meta.url === pathToFileURL(entry).href) {
|
|
454
|
+
main().catch((error) => {
|
|
455
|
+
if (error instanceof Error && error.code === "ERR_MODULE_NOT_FOUND") {
|
|
456
|
+
process.stderr.write(
|
|
457
|
+
"standup-mr MCP server requires the optional dependency @modelcontextprotocol/sdk. Install it with: npm install @modelcontextprotocol/sdk\n"
|
|
458
|
+
);
|
|
459
|
+
process.exitCode = 1;
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
throw error;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
export {
|
|
466
|
+
collect,
|
|
467
|
+
main
|
|
468
|
+
};
|
package/mcp/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# MCP server
|
|
2
|
+
|
|
3
|
+
Exposes one tool, `get_standup_data`, returning the same JSON as `standup fetch`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install standup-mr @modelcontextprotocol/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configure
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"standup": {
|
|
17
|
+
"command": "node",
|
|
18
|
+
"args": ["/absolute/path/to/standup-mr/dist/mcp/server.js"],
|
|
19
|
+
"env": {
|
|
20
|
+
"GITLAB_HOST": "gitlab.example.com",
|
|
21
|
+
"GITLAB_TOKEN": "glpat-..."
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`GITLAB_HOST` and `GITLAB_TOKEN` are the supported configuration for MCP use. If
|
|
29
|
+
`glab` happens to be installed and authenticated it is used as a fallback, but do
|
|
30
|
+
not rely on that inside a container.
|
|
31
|
+
|
|
32
|
+
The tool returns data only. Ask your client to write the note, or use the Claude
|
|
33
|
+
Code skill in `skill/`, which carries the note-writing rules.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "standup-mr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standup notes from merge request state, not commit logs.",
|
|
5
|
+
"keywords": ["standup", "gitlab", "merge-request", "cli", "mcp"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "İlker Balcılar",
|
|
9
|
+
"email": "ilkerbalcilartr@gmail.com"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/Jubstaaa/standup-mr.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/Jubstaaa/standup-mr/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/Jubstaaa/standup-mr#readme",
|
|
19
|
+
"funding": [
|
|
20
|
+
{
|
|
21
|
+
"type": "github",
|
|
22
|
+
"url": "https://github.com/sponsors/Jubstaaa"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"type": "buy_me_a_coffee",
|
|
26
|
+
"url": "https://buymeacoffee.com/jubstaa"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"engines": { "node": ">=20" },
|
|
31
|
+
"bin": { "standup": "./dist/cli.js" },
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"files": ["dist", "skill", "mcp/README.md", "README.md", "LICENSE"],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"test": "bun test",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"prepublishOnly": "tsup"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
43
|
+
"@types/bun": "^1.0.0",
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"tsup": "^8.0.0",
|
|
46
|
+
"typescript": "^5.5.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"@modelcontextprotocol/sdk": { "optional": true }
|
|
53
|
+
}
|
|
54
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: standup
|
|
3
|
+
description: >-
|
|
4
|
+
Generates a daily standup note from GitLab merge request state. Groups the
|
|
5
|
+
previous working day's activity into themes, reports which merge requests are
|
|
6
|
+
ready to merge or blocked, and diagnoses failed pipelines from their job logs.
|
|
7
|
+
Use for "/standup", "standup note", "what did I do yesterday", "what's on my
|
|
8
|
+
plate today", "daily note".
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Standup Note
|
|
12
|
+
|
|
13
|
+
Goal: put the things worth *saying* in front of the user before their standup.
|
|
14
|
+
Not a commit log — what finished, what is waiting, what is stuck.
|
|
15
|
+
|
|
16
|
+
## 1. Collect
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx standup-mr fetch
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Prints one JSON document. Add `--lang tr` for Turkish date labels. Nothing else
|
|
23
|
+
needs running: the command resolves the host and token, works out which day to
|
|
24
|
+
report on, and reads failed job logs itself.
|
|
25
|
+
|
|
26
|
+
If the command exits non-zero, relay its stderr verbatim — do not invent a
|
|
27
|
+
cause. The usual fix is `glab auth login`, or setting `GITLAB_HOST` and
|
|
28
|
+
`GITLAB_TOKEN`.
|
|
29
|
+
|
|
30
|
+
## 2. Shape of the JSON
|
|
31
|
+
|
|
32
|
+
| Field | Contents |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `previous` | Last active day: `date`, `label`, `gapDays`, `eventCount` |
|
|
35
|
+
| `previousEvents` | That day's events — `action`, `project`, `branch`, `commits`, `commitTitle` |
|
|
36
|
+
| `todayEvents` | Anything already done today (usually empty) |
|
|
37
|
+
| `myMrs` | Open merge requests with `bucket`: `ready` / `blocked` / `draft` / `stale`, plus `pipelineMissing` |
|
|
38
|
+
| `reviews` | Review requests, with `fresh` and `approvedByMe` |
|
|
39
|
+
| `reviewPendingCount` | Reviews genuinely awaiting action |
|
|
40
|
+
| `blockers` | Failed pipelines with real error lines from the job trace |
|
|
41
|
+
|
|
42
|
+
## 3. Write the note
|
|
43
|
+
|
|
44
|
+
Three sections: **Previous day · Today · Blockers**. Short, in the language the
|
|
45
|
+
user is speaking, phrased the way they would say it out loud.
|
|
46
|
+
|
|
47
|
+
### Previous day
|
|
48
|
+
|
|
49
|
+
Use `previous.label`. Never say "yesterday" — after a weekend or a day off it is
|
|
50
|
+
wrong, and the label already carries the right day.
|
|
51
|
+
|
|
52
|
+
**Group by theme; never list events one by one.** A 60-event day should collapse
|
|
53
|
+
to four to six bullets. Group on project + branch + subject:
|
|
54
|
+
|
|
55
|
+
> **Virtual keyboard — two fixes, two releases** (`acme/ui` → main)
|
|
56
|
+
> - keeps scaling on large screens, no longer widens modals → **0.5.13**
|
|
57
|
+
> - mirrors the settled field value, not the in-flight one → **0.5.14**
|
|
58
|
+
|
|
59
|
+
`commitTitle` values are conventional commits; use the scope as the grouping
|
|
60
|
+
hint. Fold merges, branch deletions and tags into the sentence rather than
|
|
61
|
+
giving them their own bullets.
|
|
62
|
+
|
|
63
|
+
When one subject spans several projects in a day — dependency alignment, a CI
|
|
64
|
+
rollout — make it **one bullet** and say how many projects it touched.
|
|
65
|
+
|
|
66
|
+
### Today
|
|
67
|
+
|
|
68
|
+
- **`ready`** → count them, give project + `!iid`.
|
|
69
|
+
- If `pipelineMissing` is true, say **"no pipeline ever ran"** explicitly. Do
|
|
70
|
+
not let it pass as green.
|
|
71
|
+
- **`blocked`** → say which: red pipeline, or unresolved comments, or both.
|
|
72
|
+
- **`draft`** → what needs finishing. Collapse drafts that share a branch prefix
|
|
73
|
+
or scope into one line, and flag it when they must merge in order.
|
|
74
|
+
- **`stale`** → a count and the oldest date. No long list.
|
|
75
|
+
|
|
76
|
+
For reviews, use `reviewPendingCount`, not the raw length of `reviews` — GitLab
|
|
77
|
+
keeps you on the reviewer list after you approve, so the raw number overstates
|
|
78
|
+
the work. List the pending ones with author and `!iid`. If the user has both
|
|
79
|
+
their own merge request and a review in the same project, note the conflict risk.
|
|
80
|
+
|
|
81
|
+
### Blockers
|
|
82
|
+
|
|
83
|
+
If `blockers` is empty, **omit the section entirely.** Never write "no blockers".
|
|
84
|
+
|
|
85
|
+
Otherwise the useful content is the lines in `errors`, not the job name. Collapse
|
|
86
|
+
several merge requests sharing one root cause into a single blocker.
|
|
87
|
+
|
|
88
|
+
> The CI token cannot pull the private packages — `npm ci` gets a 404. Two
|
|
89
|
+
> pipelines are red for the same reason; the third passes because it has no such
|
|
90
|
+
> dependency.
|
|
91
|
+
|
|
92
|
+
A private package registry returning **404 instead of 403** usually means the
|
|
93
|
+
token's scope is wrong, not that the package is missing. Say so when the pattern
|
|
94
|
+
fits.
|
|
95
|
+
|
|
96
|
+
## Rules
|
|
97
|
+
|
|
98
|
+
- Speak from the data the command returned. If unsure about a merge request,
|
|
99
|
+
verify it rather than guessing.
|
|
100
|
+
- If `todayEvents` is non-empty, do not say "nothing done today" — call out the
|
|
101
|
+
work already started.
|
|
102
|
+
- The note gets read aloud: one breath per bullet, no three-line paragraphs.
|