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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 İlker Balcılar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # standup-mr
2
+
3
+ Standup notes from **merge request state**, not commit logs.
4
+
5
+ Most standup tools read your local `git log`. That answers "what did I type",
6
+ which is not what anyone asks in a standup. This one reads GitLab: what is ready
7
+ to merge, what is blocked, what is waiting on you — and when a pipeline is red,
8
+ it opens the job log and tells you **why**.
9
+
10
+ ## What makes it different
11
+
12
+ | | commit-log tools | standup-mr |
13
+ |---|---|---|
14
+ | Source | local `git log` | GitLab API |
15
+ | Merge request state | ✗ | ready / blocked / draft / stale |
16
+ | Review queue | ✗ | pending only, approvals filtered out |
17
+ | Failed pipeline | ✗ | error lines pulled from the job trace |
18
+ | Self-hosted GitLab | varies | first class |
19
+
20
+ ## Use
21
+
22
+ ```bash
23
+ npx standup-mr fetch # JSON
24
+ npx standup-mr fetch --markdown # structured digest
25
+ npx standup-mr fetch --lang tr # Turkish date labels
26
+ ```
27
+
28
+ Credentials resolve in this order, independently for host and token:
29
+
30
+ 1. `--host` / `--token`
31
+ 2. `GITLAB_HOST` / `GITLAB_TOKEN`
32
+ 3. the local [`glab`](https://gitlab.com/gitlab-org/cli) CLI config
33
+
34
+ So if you already use `glab`, there is nothing to configure.
35
+
36
+ ### Post it to chat
37
+
38
+ ```bash
39
+ npx standup-mr fetch --markdown | npx standup-mr post --slack "$SLACK_WEBHOOK_URL"
40
+ ```
41
+
42
+ ## The three surfaces
43
+
44
+ **CLI** — the core. Emits JSON; zero runtime dependencies.
45
+
46
+ **MCP server** (`mcp/`) — one tool, `get_standup_data`, for Claude Desktop,
47
+ Cursor, or any MCP client. See [`mcp/README.md`](mcp/README.md).
48
+
49
+ **Claude Code skill** (`skill/`) — the note-writing playbook. Copy it in:
50
+
51
+ ```bash
52
+ cp -r skill ~/.claude/skills/standup
53
+ ```
54
+
55
+ Then type `/standup`.
56
+
57
+ ## `--markdown` is a digest, not a written note
58
+
59
+ `--markdown` organizes the raw material into readable sections. It does **not**
60
+ group events into themes or diagnose blockers — that is the model's work, and it
61
+ lives in the MCP client's prompt or in the Claude Code skill.
62
+
63
+ - Without AI: a structured digest.
64
+ - With AI: a note you can read out.
65
+
66
+ ## Requirements
67
+
68
+ Node 20 or newer. No runtime dependencies.
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,551 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/buckets/buckets.constants.ts
4
+ var STALE_DAYS = 14;
5
+
6
+ // src/dates/dates.constants.ts
7
+ var DAYS = {
8
+ en: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
9
+ tr: ["Pazar", "Pazartesi", "Sal\u0131", "\xC7ar\u015Famba", "Per\u015Fembe", "Cuma", "Cumartesi"]
10
+ };
11
+ var MONTHS = {
12
+ en: [
13
+ "January",
14
+ "February",
15
+ "March",
16
+ "April",
17
+ "May",
18
+ "June",
19
+ "July",
20
+ "August",
21
+ "September",
22
+ "October",
23
+ "November",
24
+ "December"
25
+ ],
26
+ tr: [
27
+ "Ocak",
28
+ "\u015Eubat",
29
+ "Mart",
30
+ "Nisan",
31
+ "May\u0131s",
32
+ "Haziran",
33
+ "Temmuz",
34
+ "A\u011Fustos",
35
+ "Eyl\xFCl",
36
+ "Ekim",
37
+ "Kas\u0131m",
38
+ "Aral\u0131k"
39
+ ]
40
+ };
41
+ var MS_PER_DAY = 864e5;
42
+
43
+ // src/dates/dates.ts
44
+ function isoDay(day) {
45
+ const year = day.getFullYear();
46
+ const month = String(day.getMonth() + 1).padStart(2, "0");
47
+ const date = String(day.getDate()).padStart(2, "0");
48
+ return `${year}-${month}-${date}`;
49
+ }
50
+ function label(day, lang = "en") {
51
+ const key = DAYS[lang] ? lang : "en";
52
+ const weekday = DAYS[key][day.getDay()];
53
+ const month = MONTHS[key][day.getMonth()];
54
+ const date = day.getDate();
55
+ return key === "tr" ? `${date} ${month} ${weekday}` : `${weekday}, ${date} ${month}`;
56
+ }
57
+ function previousActiveDay(eventDates, today) {
58
+ const cutoff = isoDay(today);
59
+ const past = [...eventDates].filter((d) => d < cutoff).sort();
60
+ const latest = past[past.length - 1];
61
+ if (!latest) return { date: null, gapDays: null };
62
+ const gapDays = Math.round(
63
+ (Date.parse(`${cutoff}T00:00:00Z`) - Date.parse(`${latest}T00:00:00Z`)) / MS_PER_DAY
64
+ );
65
+ return { date: latest, gapDays };
66
+ }
67
+
68
+ // src/buckets/buckets.ts
69
+ function classify(mr, today, staleDays = STALE_DAYS) {
70
+ if (mr.draft) return "draft";
71
+ if (mr.pipeline === "failed" || mr.unresolved > 0) return "blocked";
72
+ const age = Math.round(
73
+ (Date.parse(`${isoDay(today)}T00:00:00Z`) - Date.parse(`${mr.updated}T00:00:00Z`)) / MS_PER_DAY
74
+ );
75
+ return age >= staleDays ? "stale" : "ready";
76
+ }
77
+ function markMissingPipelines(mrs) {
78
+ const withCi = new Set(mrs.filter((mr) => mr.pipeline).map((mr) => mr.project));
79
+ for (const mr of mrs) {
80
+ mr.pipelineMissing = mr.pipeline === null && withCi.has(mr.project);
81
+ }
82
+ }
83
+
84
+ // src/config/config.ts
85
+ import { execFileSync } from "child_process";
86
+
87
+ // src/config/config.constants.ts
88
+ var GLAB_TIMEOUT_MS = 15e3;
89
+
90
+ // src/config/config.ts
91
+ var ConfigError = class extends Error {
92
+ constructor(message) {
93
+ super(message);
94
+ this.name = "ConfigError";
95
+ }
96
+ };
97
+ function resolveHost(cliHost, envHost, glabHostList) {
98
+ if (cliHost) return cliHost;
99
+ if (envHost) return envHost;
100
+ const hosts = glabHostList ?? [];
101
+ if (hosts.length === 1) return hosts[0];
102
+ if (hosts.length === 0) {
103
+ throw new ConfigError(
104
+ "No GitLab host configured. Pass --host, set GITLAB_HOST, or log in with `glab auth login`."
105
+ );
106
+ }
107
+ throw new ConfigError(
108
+ `Multiple GitLab hosts found (${[...hosts].sort().join(", ")}). Pick one with --host or GITLAB_HOST.`
109
+ );
110
+ }
111
+ function resolveToken(host, cliToken, envToken, glabLookup) {
112
+ if (cliToken) return cliToken;
113
+ if (envToken) return envToken;
114
+ if (glabLookup) {
115
+ const token = glabLookup(host);
116
+ if (token) return token;
117
+ }
118
+ throw new ConfigError(
119
+ `No token for ${host}. Pass --token, set GITLAB_TOKEN, or run \`glab auth login --hostname ${host}\`.`
120
+ );
121
+ }
122
+ function glab(args) {
123
+ try {
124
+ return execFileSync("glab", args, {
125
+ encoding: "utf8",
126
+ timeout: GLAB_TIMEOUT_MS,
127
+ stdio: ["ignore", "pipe", "ignore"]
128
+ }).trim();
129
+ } catch {
130
+ return "";
131
+ }
132
+ }
133
+ function parseGlabHosts(statusOutput) {
134
+ const hosts = [...statusOutput.matchAll(/Logged in to (\S+)/g)].map((match) => match[1]);
135
+ return [...new Set(hosts)].sort();
136
+ }
137
+ function glabStatus() {
138
+ try {
139
+ return execFileSync("glab", ["auth", "status"], {
140
+ encoding: "utf8",
141
+ timeout: GLAB_TIMEOUT_MS,
142
+ stdio: ["ignore", "pipe", "pipe"]
143
+ });
144
+ } catch (error) {
145
+ const result = error;
146
+ return `${result.stdout ?? ""}${result.stderr ?? ""}`;
147
+ }
148
+ }
149
+ function glabHosts() {
150
+ return parseGlabHosts(glabStatus());
151
+ }
152
+ function glabToken(host) {
153
+ return glab(["config", "get", "token", "--host", host]);
154
+ }
155
+
156
+ // src/notify/notify.constants.ts
157
+ var PAYLOAD_FIELD = { slack: "text", discord: "content" };
158
+
159
+ // src/notify/notify.ts
160
+ async function postWebhook(url, text, kind = "slack", fetchImpl = fetch) {
161
+ const field = PAYLOAD_FIELD[kind];
162
+ if (!field) {
163
+ throw new Error(
164
+ `Unknown webhook kind "${kind}". Use one of: ${Object.keys(PAYLOAD_FIELD).join(", ")}.`
165
+ );
166
+ }
167
+ const response = await fetchImpl(url, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ [field]: text })
171
+ });
172
+ if (!response.ok) {
173
+ throw new Error(`Webhook rejected the message: HTTP ${response.status}.`);
174
+ }
175
+ }
176
+
177
+ // src/trace/trace.constants.ts
178
+ var ANSI = /\x1b\[[0-9;]*[a-zA-Z]/g;
179
+ var SECTION = /section_(start|end):\d+:\S*/g;
180
+ var SIGNAL = /(\berror\b|Error:|fatal:|npm ERR!|\bfailed\b)/i;
181
+ var NOISE = /^(Cleaning up|Job failed: exit status|ERROR: Job failed|Uploading artifacts|Job succeeded)/i;
182
+ var MAX_LINE = 200;
183
+
184
+ // src/trace/trace.ts
185
+ function extractErrors(rawTrace, limit = 8) {
186
+ if (!rawTrace) return [];
187
+ const cleaned = rawTrace.replace(ANSI, "").replace(SECTION, "");
188
+ const seen = /* @__PURE__ */ new Set();
189
+ const hits = [];
190
+ for (const rawLine of cleaned.split("\n")) {
191
+ const line = rawLine.trim();
192
+ if (!line || NOISE.test(line) || !SIGNAL.test(line)) continue;
193
+ const clipped = line.slice(0, MAX_LINE);
194
+ if (seen.has(clipped)) continue;
195
+ seen.add(clipped);
196
+ hits.push(clipped);
197
+ }
198
+ return hits.slice(-limit);
199
+ }
200
+
201
+ // src/providers/gitlab/gitlab.constants.ts
202
+ var PAGE_SIZE = 100;
203
+ var FRESH_REVIEW_DAYS = 7;
204
+
205
+ // src/providers/gitlab/gitlab.ts
206
+ var GitLabProvider = class {
207
+ host;
208
+ api;
209
+ token;
210
+ fetchImpl;
211
+ constructor(host, token, fetchImpl = fetch) {
212
+ this.host = host;
213
+ this.token = token;
214
+ this.api = `https://${host}/api/v4`;
215
+ this.fetchImpl = fetchImpl;
216
+ }
217
+ url(path, params) {
218
+ const base = `${this.api}/${path}`;
219
+ if (!params || Object.keys(params).length === 0) return base;
220
+ const query = new URLSearchParams();
221
+ for (const [key, value] of Object.entries(params)) {
222
+ query.set(key, String(value));
223
+ }
224
+ return `${base}?${query.toString()}`;
225
+ }
226
+ async getJson(path, params) {
227
+ try {
228
+ const response = await this.fetchImpl(this.url(path, params), {
229
+ headers: { "PRIVATE-TOKEN": this.token }
230
+ });
231
+ if (!response.ok) return null;
232
+ return await response.json();
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+ async getText(path) {
238
+ try {
239
+ const response = await this.fetchImpl(this.url(path), {
240
+ headers: { "PRIVATE-TOKEN": this.token }
241
+ });
242
+ if (!response.ok) return "";
243
+ return await response.text();
244
+ } catch {
245
+ return "";
246
+ }
247
+ }
248
+ async getPaged(path, params = {}, cap = 5) {
249
+ const rows = [];
250
+ for (let page = 1; page <= cap; page += 1) {
251
+ const chunk = await this.getJson(path, {
252
+ ...params,
253
+ per_page: PAGE_SIZE,
254
+ page
255
+ });
256
+ if (!chunk || chunk.length === 0) break;
257
+ rows.push(...chunk);
258
+ if (chunk.length < PAGE_SIZE) break;
259
+ }
260
+ return rows;
261
+ }
262
+ async getIdentity() {
263
+ const me = await this.getJson("user");
264
+ if (!me) {
265
+ throw new Error(
266
+ `Could not reach ${this.host}. Check the host, the token, and network access.`
267
+ );
268
+ }
269
+ return { id: me.id, username: me.username };
270
+ }
271
+ async projectPath(projectId) {
272
+ const project = await this.getJson(
273
+ `projects/${projectId}`
274
+ );
275
+ return project?.path_with_namespace ?? String(projectId);
276
+ }
277
+ async getEvents(since) {
278
+ const raw = await this.getPaged("events", {
279
+ after: isoDay(since)
280
+ });
281
+ const ids = [...new Set(raw.map((e) => e.project_id).filter(Boolean))];
282
+ const paths = new Map(
283
+ await Promise.all(
284
+ ids.map(async (id) => [id, await this.projectPath(id)])
285
+ )
286
+ );
287
+ return raw.map((event) => {
288
+ const push = event.push_data ?? {};
289
+ return {
290
+ at: String(event.created_at).slice(0, 16),
291
+ action: event.action_name,
292
+ project: paths.get(event.project_id) ?? "",
293
+ targetType: event.target_type ?? "",
294
+ title: event.target_title ?? "",
295
+ branch: push.ref ?? "",
296
+ commits: push.commit_count ?? 0,
297
+ commitTitle: push.commit_title ?? ""
298
+ };
299
+ }).sort((a, b) => a.at.localeCompare(b.at));
300
+ }
301
+ async countUnresolved(projectId, iid) {
302
+ const discussions = await this.getJson(
303
+ `projects/${projectId}/merge_requests/${iid}/discussions`,
304
+ { per_page: PAGE_SIZE }
305
+ );
306
+ if (!discussions) return 0;
307
+ return discussions.filter((discussion) => {
308
+ const notes = (discussion.notes ?? []).filter((n) => !n.system);
309
+ return notes.length > 0 && notes.some((n) => n.resolvable && !n.resolved);
310
+ }).length;
311
+ }
312
+ async shapeMr(mr) {
313
+ const projectId = mr.project_id;
314
+ const iid = mr.iid;
315
+ const [pipelines, unresolved] = await Promise.all([
316
+ this.getJson(
317
+ `projects/${projectId}/merge_requests/${iid}/pipelines`,
318
+ { per_page: 1 }
319
+ ),
320
+ this.countUnresolved(projectId, iid)
321
+ ]);
322
+ const latest = pipelines?.[0];
323
+ return {
324
+ project: String(mr.references.full).split("!")[0],
325
+ projectId,
326
+ iid,
327
+ title: mr.title,
328
+ draft: mr.draft,
329
+ branch: mr.source_branch,
330
+ target: mr.target_branch,
331
+ updated: String(mr.updated_at).slice(0, 10),
332
+ url: mr.web_url,
333
+ mergeStatus: mr.detailed_merge_status ?? null,
334
+ pipeline: latest?.status ?? null,
335
+ pipelineId: latest?.id ?? null,
336
+ unresolved,
337
+ pipelineMissing: false,
338
+ bucket: "ready"
339
+ };
340
+ }
341
+ async getMyMrs(today) {
342
+ const raw = await this.getPaged("merge_requests", {
343
+ scope: "created_by_me",
344
+ state: "opened"
345
+ });
346
+ const rows = await Promise.all(raw.map((mr) => this.shapeMr(mr)));
347
+ markMissingPipelines(rows);
348
+ for (const row of rows) {
349
+ row.bucket = classify(row, today);
350
+ }
351
+ return rows;
352
+ }
353
+ async approvedByMe(projectId, iid, uid) {
354
+ const approvals = await this.getJson(`projects/${projectId}/merge_requests/${iid}/approvals`);
355
+ if (!approvals) return false;
356
+ return (approvals.approved_by ?? []).some((entry) => entry.user?.id === uid);
357
+ }
358
+ async getReviews(uid, today) {
359
+ const raw = await this.getPaged("merge_requests", {
360
+ scope: "all",
361
+ state: "opened",
362
+ reviewer_id: uid
363
+ });
364
+ const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS * MS_PER_DAY));
365
+ const rows = await Promise.all(
366
+ raw.map(async (mr) => ({
367
+ project: String(mr.references.full).split("!")[0],
368
+ iid: mr.iid,
369
+ title: mr.title,
370
+ author: mr.author.name,
371
+ updated: String(mr.updated_at).slice(0, 10),
372
+ draft: mr.draft,
373
+ url: mr.web_url,
374
+ fresh: String(mr.updated_at).slice(0, 10) >= cutoff,
375
+ approvedByMe: await this.approvedByMe(mr.project_id, mr.iid, uid)
376
+ }))
377
+ );
378
+ return rows.sort((a, b) => b.updated.localeCompare(a.updated));
379
+ }
380
+ async getBlockers(mrs) {
381
+ const red = mrs.filter((mr) => mr.pipeline === "failed");
382
+ const diagnosed = await Promise.all(
383
+ red.map(async (mr) => {
384
+ const jobs = await this.getJson(
385
+ `projects/${mr.projectId}/pipelines/${mr.pipelineId}/jobs`,
386
+ { per_page: PAGE_SIZE }
387
+ ) ?? [];
388
+ const job = jobs.find((j) => j.status === "failed");
389
+ if (!job) return null;
390
+ const trace = await this.getText(
391
+ `projects/${mr.projectId}/jobs/${job.id}/trace`
392
+ );
393
+ return {
394
+ project: mr.project,
395
+ mr: mr.iid,
396
+ title: mr.title,
397
+ job: job.name,
398
+ stage: job.stage,
399
+ url: mr.url,
400
+ errors: extractErrors(trace)
401
+ };
402
+ })
403
+ );
404
+ return diagnosed.filter((row) => row !== null);
405
+ }
406
+ };
407
+
408
+ // src/render/render.constants.ts
409
+ var STRINGS = {
410
+ en: {
411
+ digest: "Structured digest \u2014 not a written note.",
412
+ previous: "Previous working day",
413
+ today: "Today",
414
+ ready: "Ready to merge",
415
+ blocked: "Blocked",
416
+ draft: "Drafts",
417
+ stale: "Stale",
418
+ reviews: "Reviews",
419
+ pending: "pending",
420
+ blockers: "Blockers",
421
+ noPipeline: "no pipeline ran",
422
+ unresolved: "unresolved comment(s)",
423
+ nothing: "No activity recorded."
424
+ },
425
+ tr: {
426
+ digest: "Yap\u0131land\u0131r\u0131lm\u0131\u015F d\xF6k\xFCm \u2014 yaz\u0131lm\u0131\u015F not de\u011Fil.",
427
+ previous: "\xD6nceki i\u015F g\xFCn\xFC",
428
+ today: "Bug\xFCn",
429
+ ready: "Merge'e haz\u0131r",
430
+ blocked: "Engelli",
431
+ draft: "Draftlar",
432
+ stale: "Bayat",
433
+ reviews: "Review",
434
+ pending: "bekliyor",
435
+ blockers: "Blocker",
436
+ noPipeline: "pipeline hi\xE7 \xE7al\u0131\u015Fmam\u0131\u015F",
437
+ unresolved: "\xE7\xF6z\xFClmemi\u015F yorum",
438
+ nothing: "Kay\u0131tl\u0131 aktivite yok."
439
+ }
440
+ };
441
+ var BUCKET_ORDER = ["ready", "blocked", "draft", "stale"];
442
+
443
+ // src/render/render.ts
444
+ function eventLine(event) {
445
+ const detail = event.commits ? `${event.commitTitle || event.branch} (${event.commits} commit)` : event.title || event.action;
446
+ return `- \`${event.project}\` ${event.action} \u2014 ${detail}`;
447
+ }
448
+ function toMarkdown(report, lang = "en") {
449
+ const t = STRINGS[lang] ?? STRINGS.en;
450
+ const out = [];
451
+ out.push(`# ${report.today.label} \u2014 ${report.user}`, "", `_${t.digest}_`, "");
452
+ out.push(`## ${t.previous}: ${report.previous.label ?? "\u2014"}`, "");
453
+ if (report.previousEvents.length > 0) {
454
+ out.push(...report.previousEvents.map(eventLine));
455
+ } else {
456
+ out.push(`_${t.nothing}_`);
457
+ }
458
+ out.push("");
459
+ if (report.todayEvents.length > 0) {
460
+ out.push(`## ${t.today}`, "", ...report.todayEvents.map(eventLine), "");
461
+ }
462
+ for (const bucket of BUCKET_ORDER) {
463
+ const rows = report.myMrs.filter((mr) => mr.bucket === bucket);
464
+ if (rows.length === 0) continue;
465
+ out.push(`## ${t[bucket]} (${rows.length})`, "");
466
+ for (const mr of rows) {
467
+ const notes = [];
468
+ if (mr.pipelineMissing) notes.push(t.noPipeline);
469
+ if (mr.unresolved) notes.push(`${mr.unresolved} ${t.unresolved}`);
470
+ const suffix = notes.length > 0 ? ` \u2014 **${notes.join(", ")}**` : "";
471
+ out.push(`- \`${mr.project}\` !${mr.iid} ${mr.title}${suffix}`);
472
+ }
473
+ out.push("");
474
+ }
475
+ const pending = report.reviews.filter((r) => !r.approvedByMe);
476
+ if (pending.length > 0) {
477
+ out.push(`## ${t.reviews} (${report.reviewPendingCount} ${t.pending})`, "");
478
+ for (const review of pending) {
479
+ out.push(
480
+ `- \`${review.project}\` !${review.iid} ${review.title} \u2014 ${review.author}`
481
+ );
482
+ }
483
+ out.push("");
484
+ }
485
+ if (report.blockers.length > 0) {
486
+ out.push(`## ${t.blockers}`, "");
487
+ for (const blocker of report.blockers) {
488
+ out.push(`- \`${blocker.project}\` !${blocker.mr} \u2014 job \`${blocker.job}\``);
489
+ out.push(...blocker.errors.map((line) => ` - \`${line}\``));
490
+ }
491
+ out.push("");
492
+ }
493
+ return `${out.join("\n").trimEnd()}
494
+ `;
495
+ }
496
+
497
+ // src/report/report.constants.ts
498
+ var LOOKBACK_DAYS = 21;
499
+
500
+ // src/report/report.ts
501
+ async function buildReport(provider, today, lang = "en", lookbackDays = LOOKBACK_DAYS) {
502
+ const identity = await provider.getIdentity();
503
+ const since = new Date(today.getTime() - lookbackDays * MS_PER_DAY);
504
+ const events = await provider.getEvents(since);
505
+ const { date: previousDate, gapDays } = previousActiveDay(
506
+ new Set(events.map((e) => e.at.slice(0, 10))),
507
+ today
508
+ );
509
+ const previousEvents = previousDate ? events.filter((e) => e.at.slice(0, 10) === previousDate) : [];
510
+ const todayEvents = events.filter((e) => e.at.slice(0, 10) === isoDay(today));
511
+ const myMrs = await provider.getMyMrs(today);
512
+ const [reviews, blockers] = await Promise.all([
513
+ provider.getReviews(identity.id, today),
514
+ provider.getBlockers(myMrs)
515
+ ]);
516
+ return {
517
+ user: identity.username,
518
+ today: { date: isoDay(today), label: label(today, lang) },
519
+ previous: {
520
+ date: previousDate,
521
+ label: previousDate ? label(/* @__PURE__ */ new Date(`${previousDate}T00:00:00`), lang) : null,
522
+ gapDays,
523
+ eventCount: previousEvents.length
524
+ },
525
+ previousEvents,
526
+ todayEvents,
527
+ myMrs,
528
+ reviews,
529
+ reviewPendingCount: reviews.filter((r) => !r.approvedByMe).length,
530
+ blockers
531
+ };
532
+ }
533
+
534
+ export {
535
+ STALE_DAYS,
536
+ isoDay,
537
+ label,
538
+ previousActiveDay,
539
+ classify,
540
+ markMissingPipelines,
541
+ ConfigError,
542
+ resolveHost,
543
+ resolveToken,
544
+ glabHosts,
545
+ glabToken,
546
+ postWebhook,
547
+ extractErrors,
548
+ GitLabProvider,
549
+ toMarkdown,
550
+ buildReport
551
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ declare function main(argv: string[]): Promise<number>;
2
+
3
+ export { main };