standup-mr 0.1.2 → 0.2.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.
@@ -0,0 +1,1108 @@
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 localAt(iso) {
51
+ const at = new Date(iso);
52
+ const hours = String(at.getHours()).padStart(2, "0");
53
+ const minutes = String(at.getMinutes()).padStart(2, "0");
54
+ return `${isoDay(at)}T${hours}:${minutes}`;
55
+ }
56
+ function label(day, lang = "en") {
57
+ const key = DAYS[lang] ? lang : "en";
58
+ const weekday = DAYS[key][day.getDay()];
59
+ const month = MONTHS[key][day.getMonth()];
60
+ const date = day.getDate();
61
+ return key === "tr" ? `${date} ${month} ${weekday}` : `${weekday}, ${date} ${month}`;
62
+ }
63
+ function isWeekday(day) {
64
+ const weekday = (/* @__PURE__ */ new Date(`${day}T00:00:00`)).getDay();
65
+ return weekday >= 1 && weekday <= 5;
66
+ }
67
+ function previousActiveDays(eventDates, today) {
68
+ const cutoff = isoDay(today);
69
+ const past = [...eventDates].filter((day) => day < cutoff).sort();
70
+ if (past.length === 0) return [];
71
+ const anchor = past.filter(isWeekday).pop() ?? past[0];
72
+ return past.filter((day) => day >= anchor).map((day) => ({
73
+ date: day,
74
+ gapDays: Math.round(
75
+ (Date.parse(`${cutoff}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) / MS_PER_DAY
76
+ )
77
+ }));
78
+ }
79
+
80
+ // src/buckets/buckets.ts
81
+ function classify(mr, today, staleDays = STALE_DAYS) {
82
+ if (mr.draft) return "draft";
83
+ if (mr.pipeline === "failed" || mr.unresolved > 0) return "blocked";
84
+ const age = Math.round(
85
+ (Date.parse(`${isoDay(today)}T00:00:00Z`) - Date.parse(`${mr.updated}T00:00:00Z`)) / MS_PER_DAY
86
+ );
87
+ return age >= staleDays ? "stale" : "ready";
88
+ }
89
+ function markMissingPipelines(mrs) {
90
+ const withCi = new Set(mrs.filter((mr) => mr.pipeline).map((mr) => mr.project));
91
+ for (const mr of mrs) {
92
+ mr.pipelineMissing = mr.pipeline === null && withCi.has(mr.project);
93
+ }
94
+ }
95
+
96
+ // src/config/config.ts
97
+ import { execFileSync } from "child_process";
98
+
99
+ // src/config/config.constants.ts
100
+ var CLI_TIMEOUT_MS = 15e3;
101
+
102
+ // src/config/config.ts
103
+ var ConfigError = class extends Error {
104
+ constructor(message) {
105
+ super(message);
106
+ this.name = "ConfigError";
107
+ }
108
+ };
109
+ var GITLAB_LABELS = {
110
+ name: "GitLab",
111
+ cli: "glab",
112
+ envHost: "GITLAB_HOST",
113
+ envToken: "GITLAB_TOKEN",
114
+ login: (host) => `glab auth login --hostname ${host}`
115
+ };
116
+ var GITHUB_LABELS = {
117
+ name: "GitHub",
118
+ cli: "gh",
119
+ envHost: "GITHUB_HOST",
120
+ envToken: "GITHUB_TOKEN",
121
+ login: (host) => `gh auth login --hostname ${host}`
122
+ };
123
+ function resolveHost(cliHost, envHost, hostList, labels = GITLAB_LABELS) {
124
+ if (cliHost) return cliHost;
125
+ if (envHost) return envHost;
126
+ const hosts = hostList ?? [];
127
+ if (hosts.length === 1) return hosts[0];
128
+ if (hosts.length === 0) {
129
+ throw new ConfigError(
130
+ `No ${labels.name} host configured. Pass --host, set ${labels.envHost}, or log in with \`${labels.cli} auth login\`.`
131
+ );
132
+ }
133
+ throw new ConfigError(
134
+ `Multiple ${labels.name} hosts found (${[...hosts].sort().join(", ")}). Pick one with --host or ${labels.envHost}.`
135
+ );
136
+ }
137
+ function resolveToken(host, cliToken, envToken, lookup, labels = GITLAB_LABELS) {
138
+ if (cliToken) return cliToken;
139
+ if (envToken) return envToken;
140
+ if (lookup) {
141
+ const token = lookup(host);
142
+ if (token) return token;
143
+ }
144
+ throw new ConfigError(
145
+ `No token for ${host}. Pass --token, set ${labels.envToken}, or run \`${labels.login(host)}\`.`
146
+ );
147
+ }
148
+ function cliCapture(cli, args) {
149
+ try {
150
+ return execFileSync(cli, args, {
151
+ encoding: "utf8",
152
+ timeout: CLI_TIMEOUT_MS,
153
+ stdio: ["ignore", "pipe", "ignore"]
154
+ }).trim();
155
+ } catch {
156
+ return "";
157
+ }
158
+ }
159
+ function cliStatus(cli) {
160
+ try {
161
+ return execFileSync(cli, ["auth", "status"], {
162
+ encoding: "utf8",
163
+ timeout: CLI_TIMEOUT_MS,
164
+ stdio: ["ignore", "pipe", "pipe"]
165
+ });
166
+ } catch (error) {
167
+ const result = error;
168
+ return `${result.stdout ?? ""}${result.stderr ?? ""}`;
169
+ }
170
+ }
171
+ function parseLoggedInHosts(statusOutput) {
172
+ const hosts = [...statusOutput.matchAll(/Logged in to (\S+)/g)].map((match) => match[1]);
173
+ return [...new Set(hosts)].sort();
174
+ }
175
+ var parseGlabHosts = parseLoggedInHosts;
176
+ function glabHosts() {
177
+ return parseLoggedInHosts(cliStatus("glab"));
178
+ }
179
+ function glabToken(host) {
180
+ return cliCapture("glab", ["config", "get", "token", "--host", host]);
181
+ }
182
+ function ghHosts() {
183
+ return parseLoggedInHosts(cliStatus("gh"));
184
+ }
185
+ function ghToken(host) {
186
+ return cliCapture("gh", ["auth", "token", "--hostname", host]);
187
+ }
188
+
189
+ // src/notify/notify.constants.ts
190
+ var PAYLOAD_FIELD = { slack: "text", discord: "content" };
191
+
192
+ // src/notify/notify.ts
193
+ async function postWebhook(url, text, kind = "slack", fetchImpl = fetch) {
194
+ const field = PAYLOAD_FIELD[kind];
195
+ if (!field) {
196
+ throw new Error(
197
+ `Unknown webhook kind "${kind}". Use one of: ${Object.keys(PAYLOAD_FIELD).join(", ")}.`
198
+ );
199
+ }
200
+ const response = await fetchImpl(url, {
201
+ method: "POST",
202
+ headers: { "Content-Type": "application/json" },
203
+ body: JSON.stringify({ [field]: text })
204
+ });
205
+ if (!response.ok) {
206
+ throw new Error(`Webhook rejected the message: HTTP ${response.status}.`);
207
+ }
208
+ }
209
+
210
+ // src/providers/base/http.ts
211
+ function buildUrl(api, path, params) {
212
+ const base = `${api}/${path}`;
213
+ if (!params || Object.keys(params).length === 0) return base;
214
+ const query = new URLSearchParams();
215
+ for (const [key, value] of Object.entries(params)) {
216
+ query.set(key, String(value));
217
+ }
218
+ return `${base}?${query.toString()}`;
219
+ }
220
+ var ApiError = class extends Error {
221
+ constructor(message) {
222
+ super(message);
223
+ this.name = "ApiError";
224
+ }
225
+ };
226
+ function remaining(response) {
227
+ return response.headers.get("x-ratelimit-remaining") ?? response.headers.get("ratelimit-remaining");
228
+ }
229
+ function resetAt(response) {
230
+ const reset = response.headers.get("x-ratelimit-reset") ?? response.headers.get("ratelimit-reset");
231
+ if (!reset) return "";
232
+ const seconds = Number(reset);
233
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
234
+ return `; resets at ${new Date(seconds * 1e3).toISOString()}`;
235
+ }
236
+ function retryAfter(response) {
237
+ const value = response.headers.get("retry-after");
238
+ if (!value) return "";
239
+ const seconds = Number(value);
240
+ if (!Number.isFinite(seconds) || seconds <= 0) return `; retry after ${value}`;
241
+ return `; retry after ${seconds}s`;
242
+ }
243
+ function assertUsable(response, host) {
244
+ if (response.ok || response.status === 404) return;
245
+ if (response.status === 403 || response.status === 429) {
246
+ if (remaining(response) === "0") {
247
+ throw new ApiError(`${host} rate limit reached${resetAt(response)}.`);
248
+ }
249
+ const retry = retryAfter(response);
250
+ if (retry) throw new ApiError(`${host} rate limit reached${retry}.`);
251
+ }
252
+ if (response.status === 401) {
253
+ throw new ApiError(
254
+ `${host} rejected the token (401). Check the token and its scopes.`
255
+ );
256
+ }
257
+ if (response.status === 403) {
258
+ throw new ApiError(
259
+ `${host} refused the request (403). The token has no access to that resource.`
260
+ );
261
+ }
262
+ throw new ApiError(`${host} returned ${response.status}.`);
263
+ }
264
+ function unreachable(host, cause) {
265
+ const detail = cause instanceof Error ? cause.message : String(cause);
266
+ return new ApiError(`Could not reach ${host}: ${detail}`);
267
+ }
268
+
269
+ // src/trace/trace.constants.ts
270
+ var ANSI = /\x1b\[[0-9;]*[a-zA-Z]/g;
271
+ var SECTION = /section_(start|end):\d+:\S*/g;
272
+ var SIGNAL = /(\berror\b|Error:|fatal:|npm ERR!|\bfailed\b)/i;
273
+ var NOISE = /^(Cleaning up|Job failed: exit status|ERROR: Job failed|Uploading artifacts|Job succeeded)/i;
274
+ var MAX_LINE = 200;
275
+ var TIMESTAMP = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/;
276
+ var ERROR_MARKER = /^##\[error\]\s*/;
277
+ var GROUP = /^##\[(group|endgroup)\]/;
278
+
279
+ // src/trace/trace.ts
280
+ function extractErrors(rawTrace, limit = 8) {
281
+ if (!rawTrace) return [];
282
+ const cleaned = rawTrace.replace(ANSI, "").replace(SECTION, "");
283
+ const seen = /* @__PURE__ */ new Set();
284
+ const hits = [];
285
+ for (const rawLine of cleaned.split("\n")) {
286
+ const stamped = rawLine.trim().replace(TIMESTAMP, "");
287
+ if (GROUP.test(stamped)) continue;
288
+ if (!stamped || NOISE.test(stamped) || !SIGNAL.test(stamped)) continue;
289
+ const line = stamped.replace(ERROR_MARKER, "").trim();
290
+ if (!line) continue;
291
+ const clipped = line.slice(0, MAX_LINE);
292
+ if (seen.has(clipped)) continue;
293
+ seen.add(clipped);
294
+ hits.push(clipped);
295
+ }
296
+ return hits.slice(-limit);
297
+ }
298
+
299
+ // src/providers/github/github.constants.ts
300
+ var PAGE_SIZE = 100;
301
+ var FRESH_REVIEW_DAYS = 7;
302
+ var API_VERSION = "2022-11-28";
303
+ var DOT_COM = "github.com";
304
+ var SEARCH_CAP = 3;
305
+ var EVENT_FEED_CAP = 300;
306
+ var EVENTS_PAGE_CAP = EVENT_FEED_CAP / PAGE_SIZE;
307
+ var FAILED_CONCLUSIONS = /* @__PURE__ */ new Set([
308
+ "failure",
309
+ "timed_out",
310
+ "startup_failure",
311
+ "action_required"
312
+ ]);
313
+ var EVENT_ACTIONS = {
314
+ PushEvent: "pushed to",
315
+ PullRequestReviewEvent: "reviewed",
316
+ PullRequestReviewCommentEvent: "commented on",
317
+ IssueCommentEvent: "commented on",
318
+ CreateEvent: "created",
319
+ DeleteEvent: "deleted"
320
+ };
321
+ var EVENT_TARGET_TYPES = {
322
+ PullRequestEvent: "MergeRequest",
323
+ PullRequestReviewEvent: "MergeRequest",
324
+ PullRequestReviewCommentEvent: "MergeRequest",
325
+ IssueCommentEvent: "Note"
326
+ };
327
+
328
+ // src/providers/github/github.map.ts
329
+ function repoFromUrl(repositoryUrl) {
330
+ const match = /\/repos\/([^/]+\/[^/]+)/.exec(repositoryUrl);
331
+ return match ? match[1] : "";
332
+ }
333
+ function mapEvent(raw) {
334
+ const payload = raw.payload ?? {};
335
+ const type = raw.type ?? "";
336
+ const commits = payload.commits ?? [];
337
+ const last = commits[commits.length - 1];
338
+ return {
339
+ at: localAt(raw.created_at),
340
+ action: type === "PullRequestEvent" ? String(payload.action ?? "updated") : EVENT_ACTIONS[type] ?? type.replace(/Event$/, "").toLowerCase(),
341
+ project: raw.repo?.name ?? "",
342
+ targetType: EVENT_TARGET_TYPES[type] ?? "",
343
+ title: payload.pull_request?.title ?? payload.issue?.title ?? "",
344
+ branch: String(payload.ref ?? "").replace(/^refs\/heads\//, ""),
345
+ commits: Number(payload.size ?? 0),
346
+ commitTitle: last ? String(last.message ?? "").split("\n")[0] : ""
347
+ };
348
+ }
349
+
350
+ // src/providers/github/github.state.ts
351
+ function normalizeChecks(runs) {
352
+ if (runs.length === 0) return { pipeline: null, pipelineId: null };
353
+ const failed = runs.find(
354
+ (run) => run.conclusion !== null && FAILED_CONCLUSIONS.has(run.conclusion)
355
+ );
356
+ if (failed) return { pipeline: "failed", pipelineId: failed.id };
357
+ if (runs.some((run) => run.status !== "completed")) {
358
+ return { pipeline: "running", pipelineId: null };
359
+ }
360
+ if (runs.some((run) => run.conclusion === "cancelled")) {
361
+ return { pipeline: "canceled", pipelineId: null };
362
+ }
363
+ return { pipeline: "success", pipelineId: null };
364
+ }
365
+ function latestStateByReviewer(reviews) {
366
+ const latest = /* @__PURE__ */ new Map();
367
+ for (const review of reviews) {
368
+ const login = review.user?.login;
369
+ if (!login) continue;
370
+ if (review.state === "COMMENTED" || review.state === "PENDING") continue;
371
+ latest.set(login, review.state);
372
+ }
373
+ return latest;
374
+ }
375
+ function countChangesRequested(reviews) {
376
+ return [...latestStateByReviewer(reviews).values()].filter(
377
+ (state) => state === "CHANGES_REQUESTED"
378
+ ).length;
379
+ }
380
+ function approvedBy(reviews, login) {
381
+ return latestStateByReviewer(reviews).get(login) === "APPROVED";
382
+ }
383
+
384
+ // src/providers/github/github.ts
385
+ var GitHubProvider = class {
386
+ kind = "github";
387
+ host;
388
+ api;
389
+ token;
390
+ fetchImpl;
391
+ identity = null;
392
+ constructor(host, token, fetchImpl = fetch) {
393
+ this.host = host;
394
+ this.api = host === DOT_COM ? "https://api.github.com" : `https://${host}/api/v3`;
395
+ this.token = token;
396
+ this.fetchImpl = fetchImpl;
397
+ }
398
+ headers() {
399
+ return {
400
+ authorization: `Bearer ${this.token}`,
401
+ accept: "application/vnd.github+json",
402
+ "x-github-api-version": API_VERSION
403
+ };
404
+ }
405
+ async send(url, init) {
406
+ try {
407
+ return await this.fetchImpl(url, { headers: this.headers(), ...init });
408
+ } catch (cause) {
409
+ throw unreachable(this.host, cause);
410
+ }
411
+ }
412
+ async getJson(path, params) {
413
+ const response = await this.send(buildUrl(this.api, path, params));
414
+ assertUsable(response, this.host);
415
+ if (!response.ok) return null;
416
+ try {
417
+ return await response.json();
418
+ } catch (cause) {
419
+ throw unreachable(this.host, cause);
420
+ }
421
+ }
422
+ async getPaged(path, params = {}, cap = 5) {
423
+ const rows = [];
424
+ for (let page = 1; page <= cap; page += 1) {
425
+ const chunk = await this.getJson(path, {
426
+ ...params,
427
+ per_page: PAGE_SIZE,
428
+ page
429
+ });
430
+ if (!chunk || chunk.length === 0) break;
431
+ rows.push(...chunk);
432
+ if (chunk.length < PAGE_SIZE) break;
433
+ }
434
+ return rows;
435
+ }
436
+ async getSearch(query, cap = 3) {
437
+ const rows = [];
438
+ for (let page = 1; page <= cap; page += 1) {
439
+ const chunk = await this.getJson("search/issues", {
440
+ q: query,
441
+ per_page: PAGE_SIZE,
442
+ page
443
+ });
444
+ const items = chunk?.items ?? [];
445
+ if (items.length === 0) break;
446
+ rows.push(...items);
447
+ if (items.length < PAGE_SIZE) break;
448
+ }
449
+ return rows;
450
+ }
451
+ async getLogText(path) {
452
+ const first = await this.send(buildUrl(this.api, path), { redirect: "manual" });
453
+ if (first.status === 404) return "";
454
+ const location = first.headers.get("location");
455
+ if (!location) {
456
+ if (first.status === 0 || first.status >= 300 && first.status < 400) return "";
457
+ assertUsable(first, this.host);
458
+ return first.ok ? await first.text() : "";
459
+ }
460
+ let blob;
461
+ try {
462
+ blob = await this.fetchImpl(location);
463
+ } catch (cause) {
464
+ throw unreachable(this.host, cause);
465
+ }
466
+ if (!blob.ok) return "";
467
+ return await blob.text();
468
+ }
469
+ async getIdentity() {
470
+ if (this.identity) return this.identity;
471
+ const me = await this.getJson("user");
472
+ if (!me) {
473
+ throw new ApiError(
474
+ `Could not read the ${this.host} user. Check the host, the token, and network access.`
475
+ );
476
+ }
477
+ this.identity = { id: me.id, username: me.login };
478
+ return this.identity;
479
+ }
480
+ async getEvents(since) {
481
+ const login = (await this.getIdentity()).username;
482
+ const raw = await this.getPaged(
483
+ `users/${encodeURIComponent(login)}/events`,
484
+ {},
485
+ EVENTS_PAGE_CAP
486
+ );
487
+ if (raw.length === 0) {
488
+ process.stderr.write(
489
+ `Warning: the ${this.host} events feed returned nothing. Private activity is only visible to a token that belongs to the same account.
490
+ `
491
+ );
492
+ }
493
+ const floor = isoDay(since);
494
+ return raw.map(mapEvent).filter((event) => event.at.slice(0, 10) >= floor).sort((a, b) => a.at.localeCompare(b.at));
495
+ }
496
+ async shapePr(item) {
497
+ const project = repoFromUrl(item.repository_url);
498
+ const iid = item.number;
499
+ const pull = await this.getJson(`repos/${project}/pulls/${iid}`);
500
+ if (!pull) return null;
501
+ const sha = pull.head?.sha ?? "";
502
+ const [checks, reviews] = await Promise.all([
503
+ sha ? this.getJson(
504
+ `repos/${project}/commits/${sha}/check-runs`,
505
+ { per_page: PAGE_SIZE }
506
+ ) : Promise.resolve(null),
507
+ this.getPaged(`repos/${project}/pulls/${iid}/reviews`, {}, 2)
508
+ ]);
509
+ const { pipeline, pipelineId } = normalizeChecks(checks?.check_runs ?? []);
510
+ return {
511
+ provider: "github",
512
+ project,
513
+ projectId: pull.base?.repo?.id ?? 0,
514
+ iid,
515
+ title: pull.title,
516
+ draft: pull.draft,
517
+ branch: pull.head?.ref ?? "",
518
+ target: pull.base?.ref ?? "",
519
+ updated: localAt(pull.updated_at).slice(0, 10),
520
+ url: pull.html_url,
521
+ mergeStatus: pull.mergeable_state ?? null,
522
+ pipeline,
523
+ pipelineId,
524
+ unresolved: countChangesRequested(reviews),
525
+ pipelineMissing: false,
526
+ bucket: "ready"
527
+ };
528
+ }
529
+ async getMyMrs(today) {
530
+ const login = (await this.getIdentity()).username;
531
+ const items = await this.getSearch(
532
+ `is:pr is:open author:${login} archived:false`,
533
+ SEARCH_CAP
534
+ );
535
+ const shaped = await Promise.all(items.map((item) => this.shapePr(item)));
536
+ const rows = shaped.filter((row) => row !== null);
537
+ markMissingPipelines(rows);
538
+ for (const row of rows) {
539
+ row.bucket = classify(row, today);
540
+ }
541
+ return rows;
542
+ }
543
+ async getReviews(identity, today) {
544
+ const items = await this.getSearch(
545
+ `is:pr is:open review-requested:${identity.username}`,
546
+ SEARCH_CAP
547
+ );
548
+ const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS * MS_PER_DAY));
549
+ const rows = await Promise.all(
550
+ items.map(async (item) => {
551
+ const project = repoFromUrl(item.repository_url);
552
+ const reviews = await this.getPaged(
553
+ `repos/${project}/pulls/${item.number}/reviews`,
554
+ {},
555
+ 2
556
+ );
557
+ const updated = localAt(item.updated_at).slice(0, 10);
558
+ return {
559
+ provider: "github",
560
+ project,
561
+ iid: item.number,
562
+ title: item.title,
563
+ author: item.user?.login ?? "",
564
+ updated,
565
+ draft: item.draft ?? false,
566
+ url: item.html_url,
567
+ fresh: updated >= cutoff,
568
+ approvedByMe: approvedBy(reviews, identity.username)
569
+ };
570
+ })
571
+ );
572
+ return rows.sort((a, b) => b.updated.localeCompare(a.updated));
573
+ }
574
+ async actionsBlocker(mr, sha) {
575
+ const runs = await this.getJson(
576
+ `repos/${mr.project}/actions/runs`,
577
+ { head_sha: sha, per_page: 10 }
578
+ );
579
+ const run = (runs?.workflow_runs ?? []).find(
580
+ (row) => row.conclusion !== null && FAILED_CONCLUSIONS.has(row.conclusion)
581
+ );
582
+ if (!run) return null;
583
+ const jobs = await this.getJson(
584
+ `repos/${mr.project}/actions/runs/${run.id}/jobs`,
585
+ { per_page: PAGE_SIZE }
586
+ );
587
+ const job = (jobs?.jobs ?? []).find(
588
+ (row) => row.conclusion !== null && FAILED_CONCLUSIONS.has(row.conclusion)
589
+ );
590
+ if (!job) return null;
591
+ const log = await this.getLogText(
592
+ `repos/${mr.project}/actions/jobs/${job.id}/logs`
593
+ );
594
+ return {
595
+ provider: "github",
596
+ project: mr.project,
597
+ mr: mr.iid,
598
+ title: mr.title,
599
+ job: job.name,
600
+ stage: run.name ?? "",
601
+ url: mr.url,
602
+ errors: extractErrors(log)
603
+ };
604
+ }
605
+ async checkRunBlocker(mr, sha) {
606
+ const checks = await this.getJson(
607
+ `repos/${mr.project}/commits/${sha}/check-runs`,
608
+ { per_page: PAGE_SIZE }
609
+ );
610
+ const failed = (checks?.check_runs ?? []).find(
611
+ (run) => run.conclusion !== null && FAILED_CONCLUSIONS.has(run.conclusion)
612
+ );
613
+ if (!failed) return null;
614
+ const summary = [failed.output?.summary ?? "", failed.output?.text ?? ""].filter(Boolean).join("\n");
615
+ const errors = extractErrors(summary);
616
+ if (errors.length === 0) return null;
617
+ return {
618
+ provider: "github",
619
+ project: mr.project,
620
+ mr: mr.iid,
621
+ title: mr.title,
622
+ job: failed.name,
623
+ stage: failed.app?.slug ?? "check",
624
+ url: mr.url,
625
+ errors
626
+ };
627
+ }
628
+ async diagnose(mr) {
629
+ const pull = await this.getJson(
630
+ `repos/${mr.project}/pulls/${mr.iid}`
631
+ );
632
+ const sha = pull?.head?.sha;
633
+ if (!sha) return null;
634
+ const actions = await this.actionsBlocker(mr, sha);
635
+ if (actions?.errors.length) return actions;
636
+ return await this.checkRunBlocker(mr, sha) ?? actions;
637
+ }
638
+ async getBlockers(mrs) {
639
+ const red = mrs.filter((mr) => mr.pipeline === "failed");
640
+ const diagnosed = await Promise.all(red.map((mr) => this.diagnose(mr)));
641
+ return diagnosed.filter((row) => row !== null);
642
+ }
643
+ };
644
+
645
+ // src/providers/gitlab/gitlab.constants.ts
646
+ var PAGE_SIZE2 = 100;
647
+ var FRESH_REVIEW_DAYS2 = 7;
648
+
649
+ // src/providers/gitlab/gitlab.ts
650
+ var GitLabProvider = class {
651
+ kind = "gitlab";
652
+ host;
653
+ api;
654
+ token;
655
+ fetchImpl;
656
+ constructor(host, token, fetchImpl = fetch) {
657
+ this.host = host;
658
+ this.token = token;
659
+ this.api = `https://${host}/api/v4`;
660
+ this.fetchImpl = fetchImpl;
661
+ }
662
+ async send(url) {
663
+ let response;
664
+ try {
665
+ response = await this.fetchImpl(url, {
666
+ headers: { "PRIVATE-TOKEN": this.token }
667
+ });
668
+ } catch (cause) {
669
+ throw unreachable(this.host, cause);
670
+ }
671
+ assertUsable(response, this.host);
672
+ return response;
673
+ }
674
+ async getJson(path, params) {
675
+ const response = await this.send(buildUrl(this.api, path, params));
676
+ if (!response.ok) return null;
677
+ try {
678
+ return await response.json();
679
+ } catch (cause) {
680
+ throw unreachable(this.host, cause);
681
+ }
682
+ }
683
+ async getText(path) {
684
+ const response = await this.send(buildUrl(this.api, path));
685
+ if (!response.ok) return "";
686
+ return await response.text();
687
+ }
688
+ async getPaged(path, params = {}, cap = 5) {
689
+ const rows = [];
690
+ for (let page = 1; page <= cap; page += 1) {
691
+ const chunk = await this.getJson(path, {
692
+ ...params,
693
+ per_page: PAGE_SIZE2,
694
+ page
695
+ });
696
+ if (!chunk || chunk.length === 0) break;
697
+ rows.push(...chunk);
698
+ if (chunk.length < PAGE_SIZE2) break;
699
+ }
700
+ return rows;
701
+ }
702
+ async getIdentity() {
703
+ const me = await this.getJson("user");
704
+ if (!me) {
705
+ throw new Error(
706
+ `Could not reach ${this.host}. Check the host, the token, and network access.`
707
+ );
708
+ }
709
+ return { id: me.id, username: me.username };
710
+ }
711
+ async projectPath(projectId) {
712
+ const project = await this.getJson(
713
+ `projects/${projectId}`
714
+ );
715
+ return project?.path_with_namespace ?? String(projectId);
716
+ }
717
+ async getEvents(since) {
718
+ const raw = await this.getPaged("events", {
719
+ after: isoDay(since)
720
+ });
721
+ const ids = [...new Set(raw.map((e) => e.project_id).filter(Boolean))];
722
+ const paths = new Map(
723
+ await Promise.all(
724
+ ids.map(async (id) => [id, await this.projectPath(id)])
725
+ )
726
+ );
727
+ return raw.map((event) => {
728
+ const push = event.push_data ?? {};
729
+ return {
730
+ at: String(event.created_at).slice(0, 16),
731
+ action: event.action_name,
732
+ project: paths.get(event.project_id) ?? "",
733
+ targetType: event.target_type ?? "",
734
+ title: event.target_title ?? "",
735
+ branch: push.ref ?? "",
736
+ commits: push.commit_count ?? 0,
737
+ commitTitle: push.commit_title ?? ""
738
+ };
739
+ }).sort((a, b) => a.at.localeCompare(b.at));
740
+ }
741
+ async countUnresolved(projectId, iid) {
742
+ const discussions = await this.getJson(
743
+ `projects/${projectId}/merge_requests/${iid}/discussions`,
744
+ { per_page: PAGE_SIZE2 }
745
+ );
746
+ if (!discussions) return 0;
747
+ return discussions.filter((discussion) => {
748
+ const notes = (discussion.notes ?? []).filter((n) => !n.system);
749
+ return notes.length > 0 && notes.some((n) => n.resolvable && !n.resolved);
750
+ }).length;
751
+ }
752
+ async shapeMr(mr) {
753
+ const projectId = mr.project_id;
754
+ const iid = mr.iid;
755
+ const [pipelines, unresolved] = await Promise.all([
756
+ this.getJson(
757
+ `projects/${projectId}/merge_requests/${iid}/pipelines`,
758
+ { per_page: 1 }
759
+ ),
760
+ this.countUnresolved(projectId, iid)
761
+ ]);
762
+ const latest = pipelines?.[0];
763
+ return {
764
+ provider: "gitlab",
765
+ project: String(mr.references.full).split("!")[0],
766
+ projectId,
767
+ iid,
768
+ title: mr.title,
769
+ draft: mr.draft,
770
+ branch: mr.source_branch,
771
+ target: mr.target_branch,
772
+ updated: String(mr.updated_at).slice(0, 10),
773
+ url: mr.web_url,
774
+ mergeStatus: mr.detailed_merge_status ?? null,
775
+ pipeline: latest?.status ?? null,
776
+ pipelineId: latest?.id ?? null,
777
+ unresolved,
778
+ pipelineMissing: false,
779
+ bucket: "ready"
780
+ };
781
+ }
782
+ async getMyMrs(today) {
783
+ const raw = await this.getPaged("merge_requests", {
784
+ scope: "created_by_me",
785
+ state: "opened"
786
+ });
787
+ const rows = await Promise.all(raw.map((mr) => this.shapeMr(mr)));
788
+ markMissingPipelines(rows);
789
+ for (const row of rows) {
790
+ row.bucket = classify(row, today);
791
+ }
792
+ return rows;
793
+ }
794
+ async approvedByMe(projectId, iid, uid) {
795
+ const approvals = await this.getJson(`projects/${projectId}/merge_requests/${iid}/approvals`);
796
+ if (!approvals) return false;
797
+ return (approvals.approved_by ?? []).some((entry) => entry.user?.id === uid);
798
+ }
799
+ async getReviews(identity, today) {
800
+ const raw = await this.getPaged("merge_requests", {
801
+ scope: "all",
802
+ state: "opened",
803
+ reviewer_id: identity.id
804
+ });
805
+ const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS2 * MS_PER_DAY));
806
+ const rows = await Promise.all(
807
+ raw.map(async (mr) => ({
808
+ provider: "gitlab",
809
+ project: String(mr.references.full).split("!")[0],
810
+ iid: mr.iid,
811
+ title: mr.title,
812
+ author: mr.author.name,
813
+ updated: String(mr.updated_at).slice(0, 10),
814
+ draft: mr.draft,
815
+ url: mr.web_url,
816
+ fresh: String(mr.updated_at).slice(0, 10) >= cutoff,
817
+ approvedByMe: await this.approvedByMe(mr.project_id, mr.iid, identity.id)
818
+ }))
819
+ );
820
+ return rows.sort((a, b) => b.updated.localeCompare(a.updated));
821
+ }
822
+ async getBlockers(mrs) {
823
+ const red = mrs.filter((mr) => mr.pipeline === "failed");
824
+ const diagnosed = await Promise.all(
825
+ red.map(async (mr) => {
826
+ const jobs = await this.getJson(
827
+ `projects/${mr.projectId}/pipelines/${mr.pipelineId}/jobs`,
828
+ { per_page: PAGE_SIZE2 }
829
+ ) ?? [];
830
+ const job = jobs.find((j) => j.status === "failed");
831
+ if (!job) return null;
832
+ const trace = await this.getText(
833
+ `projects/${mr.projectId}/jobs/${job.id}/trace`
834
+ );
835
+ return {
836
+ provider: "gitlab",
837
+ project: mr.project,
838
+ mr: mr.iid,
839
+ title: mr.title,
840
+ job: job.name,
841
+ stage: job.stage,
842
+ url: mr.url,
843
+ errors: extractErrors(trace)
844
+ };
845
+ })
846
+ );
847
+ return diagnosed.filter((row) => row !== null);
848
+ }
849
+ };
850
+
851
+ // src/providers/select.ts
852
+ var DEFAULT_PROBE = { gitlab: glabHosts, github: ghHosts };
853
+ function hostFrom(cliHost, envHost, probe, fallback, labels) {
854
+ if (cliHost) return cliHost;
855
+ if (envHost) return envHost;
856
+ const found = probe();
857
+ return resolveHost(void 0, void 0, found.length > 0 ? found : fallback, labels);
858
+ }
859
+ function chooseKind(options = {}) {
860
+ const env = options.env ?? process.env;
861
+ const probe = options.probe ?? DEFAULT_PROBE;
862
+ if (options.provider) {
863
+ if (options.provider === "github" || options.provider === "gitlab") {
864
+ return options.provider;
865
+ }
866
+ throw new ConfigError(
867
+ `Unknown provider "${options.provider}" for --provider. Use github or gitlab.`
868
+ );
869
+ }
870
+ if (options.host) {
871
+ const host = options.host.toLowerCase();
872
+ if (host === DOT_COM || host.includes("github")) return "github";
873
+ if (host === "gitlab.com" || host.includes("gitlab")) return "gitlab";
874
+ }
875
+ if (env.STANDUP_PROVIDER) {
876
+ if (env.STANDUP_PROVIDER === "github" || env.STANDUP_PROVIDER === "gitlab") {
877
+ return env.STANDUP_PROVIDER;
878
+ }
879
+ throw new ConfigError(
880
+ `Unknown provider "${env.STANDUP_PROVIDER}" for STANDUP_PROVIDER. Use github or gitlab.`
881
+ );
882
+ }
883
+ const hasGitHubEnv = Boolean(env.GITHUB_HOST || env.GITHUB_TOKEN);
884
+ const hasGitLabEnv = Boolean(env.GITLAB_HOST || env.GITLAB_TOKEN);
885
+ if (hasGitHubEnv !== hasGitLabEnv) return hasGitHubEnv ? "github" : "gitlab";
886
+ if (hasGitHubEnv && hasGitLabEnv) {
887
+ const ghHost = Boolean(env.GITHUB_HOST);
888
+ const glabHost = Boolean(env.GITLAB_HOST);
889
+ if (ghHost !== glabHost) return ghHost ? "github" : "gitlab";
890
+ throw new ConfigError(
891
+ "Both GITHUB_* and GITLAB_* are configured. Pass --provider github or --provider gitlab."
892
+ );
893
+ }
894
+ const ghLoggedIn = probe.github().length > 0;
895
+ const glabLoggedIn = probe.gitlab().length > 0;
896
+ if (ghLoggedIn !== glabLoggedIn) return ghLoggedIn ? "github" : "gitlab";
897
+ if (ghLoggedIn && glabLoggedIn) {
898
+ throw new ConfigError(
899
+ "Both gh and glab are authenticated. Pass --provider github or --provider gitlab."
900
+ );
901
+ }
902
+ throw new ConfigError(
903
+ "No provider configured. Pass --provider with --host and --token, set GITHUB_* or GITLAB_* environment variables, or log in with gh or glab."
904
+ );
905
+ }
906
+ function connect(options = {}) {
907
+ const env = options.env ?? process.env;
908
+ const probe = options.probe ?? DEFAULT_PROBE;
909
+ if (chooseKind(options) === "github") {
910
+ const host2 = hostFrom(options.host, env.GITHUB_HOST, probe.github, [DOT_COM], GITHUB_LABELS);
911
+ const token2 = resolveToken(
912
+ host2,
913
+ options.token,
914
+ env.GITHUB_TOKEN,
915
+ ghToken,
916
+ GITHUB_LABELS
917
+ );
918
+ return new GitHubProvider(host2, token2);
919
+ }
920
+ const host = hostFrom(options.host, env.GITLAB_HOST, probe.gitlab, [], GITLAB_LABELS);
921
+ const token = resolveToken(
922
+ host,
923
+ options.token,
924
+ env.GITLAB_TOKEN,
925
+ glabToken,
926
+ GITLAB_LABELS
927
+ );
928
+ return new GitLabProvider(host, token);
929
+ }
930
+
931
+ // src/render/render.constants.ts
932
+ var STRINGS = {
933
+ en: {
934
+ digest: "Structured digest \u2014 not a written note.",
935
+ previous: "Previous working day",
936
+ today: "Today",
937
+ ready: "Ready to merge",
938
+ blocked: "Blocked",
939
+ draft: "Drafts",
940
+ stale: "Stale",
941
+ reviews: "Reviews",
942
+ pending: "pending",
943
+ blockers: "Blockers",
944
+ noPipeline: "no pipeline ran",
945
+ unresolved: "unresolved comment(s)",
946
+ nothing: "No activity recorded."
947
+ },
948
+ tr: {
949
+ digest: "Yap\u0131land\u0131r\u0131lm\u0131\u015F d\xF6k\xFCm \u2014 yaz\u0131lm\u0131\u015F not de\u011Fil.",
950
+ previous: "\xD6nceki i\u015F g\xFCn\xFC",
951
+ today: "Bug\xFCn",
952
+ ready: "Merge'e haz\u0131r",
953
+ blocked: "Engelli",
954
+ draft: "Draftlar",
955
+ stale: "Bayat",
956
+ reviews: "Review",
957
+ pending: "bekliyor",
958
+ blockers: "Blocker",
959
+ noPipeline: "pipeline hi\xE7 \xE7al\u0131\u015Fmam\u0131\u015F",
960
+ unresolved: "\xE7\xF6z\xFClmemi\u015F yorum",
961
+ nothing: "Kay\u0131tl\u0131 aktivite yok."
962
+ }
963
+ };
964
+ var BUCKET_ORDER = ["ready", "blocked", "draft", "stale"];
965
+ var REF_PREFIX = {
966
+ gitlab: "!",
967
+ github: "#"
968
+ };
969
+ var PROVIDER_STRINGS = {
970
+ gitlab: {},
971
+ github: {
972
+ en: { unresolved: "change request(s)" },
973
+ tr: { unresolved: "de\u011Fi\u015Fiklik iste\u011Fi" }
974
+ }
975
+ };
976
+
977
+ // src/render/render.ts
978
+ function eventLine(event) {
979
+ const detail = event.commits ? `${event.commitTitle || event.branch} (${event.commits} commit)` : event.title || event.action;
980
+ return `- \`${event.project}\` ${event.action} \u2014 ${detail}`;
981
+ }
982
+ function toMarkdown(report, lang = "en") {
983
+ const base = STRINGS[lang] ?? STRINGS.en;
984
+ const t = { ...base, ...PROVIDER_STRINGS[report.provider][lang] ?? {} };
985
+ const ref = REF_PREFIX[report.provider];
986
+ const out = [];
987
+ out.push(`# ${report.today.label} \u2014 ${report.user}`, "", `_${t.digest}_`, "");
988
+ if (report.previousDays.length === 0) {
989
+ out.push(`## ${t.previous}`, "", `_${t.nothing}_`, "");
990
+ }
991
+ for (const day of report.previousDays) {
992
+ out.push(`## ${t.previous}: ${day.label}`, "");
993
+ out.push(...day.events.map(eventLine));
994
+ out.push("");
995
+ }
996
+ if (report.todayEvents.length > 0) {
997
+ out.push(`## ${t.today}`, "", ...report.todayEvents.map(eventLine), "");
998
+ }
999
+ for (const bucket of BUCKET_ORDER) {
1000
+ const rows = report.myMrs.filter((mr) => mr.bucket === bucket);
1001
+ if (rows.length === 0) continue;
1002
+ out.push(`## ${t[bucket]} (${rows.length})`, "");
1003
+ for (const mr of rows) {
1004
+ const notes = [];
1005
+ if (mr.pipelineMissing) notes.push(t.noPipeline);
1006
+ if (mr.unresolved) notes.push(`${mr.unresolved} ${t.unresolved}`);
1007
+ const suffix = notes.length > 0 ? ` \u2014 **${notes.join(", ")}**` : "";
1008
+ out.push(`- \`${mr.project}\` ${ref}${mr.iid} ${mr.title}${suffix}`);
1009
+ }
1010
+ out.push("");
1011
+ }
1012
+ const pending = report.reviews.filter((r) => !r.approvedByMe);
1013
+ if (pending.length > 0) {
1014
+ out.push(`## ${t.reviews} (${report.reviewPendingCount} ${t.pending})`, "");
1015
+ for (const review of pending) {
1016
+ out.push(
1017
+ `- \`${review.project}\` ${ref}${review.iid} ${review.title} \u2014 ${review.author}`
1018
+ );
1019
+ }
1020
+ out.push("");
1021
+ }
1022
+ if (report.blockers.length > 0) {
1023
+ out.push(`## ${t.blockers}`, "");
1024
+ for (const blocker of report.blockers) {
1025
+ out.push(`- \`${blocker.project}\` ${ref}${blocker.mr} \u2014 job \`${blocker.job}\``);
1026
+ out.push(...blocker.errors.map((line) => ` - \`${line}\``));
1027
+ }
1028
+ out.push("");
1029
+ }
1030
+ return `${out.join("\n").trimEnd()}
1031
+ `;
1032
+ }
1033
+
1034
+ // src/report/report.constants.ts
1035
+ var LOOKBACK_DAYS = 21;
1036
+
1037
+ // src/report/report.ts
1038
+ async function buildReport(provider, today, lang = "en", lookbackDays = LOOKBACK_DAYS) {
1039
+ const identity = await provider.getIdentity();
1040
+ const since = new Date(today.getTime() - lookbackDays * MS_PER_DAY);
1041
+ const events = await provider.getEvents(since);
1042
+ const days = previousActiveDays(
1043
+ new Set(events.map((e) => e.at.slice(0, 10))),
1044
+ today
1045
+ );
1046
+ const previousDays = days.map(({ date, gapDays }) => ({
1047
+ date,
1048
+ label: label(/* @__PURE__ */ new Date(`${date}T00:00:00`), lang),
1049
+ gapDays,
1050
+ events: events.filter((e) => e.at.slice(0, 10) === date)
1051
+ }));
1052
+ const todayEvents = events.filter((e) => e.at.slice(0, 10) === isoDay(today));
1053
+ const myMrs = await provider.getMyMrs(today);
1054
+ const [reviews, blockers] = await Promise.all([
1055
+ provider.getReviews(identity, today),
1056
+ provider.getBlockers(myMrs)
1057
+ ]);
1058
+ return {
1059
+ provider: provider.kind,
1060
+ user: identity.username,
1061
+ today: { date: isoDay(today), label: label(today, lang) },
1062
+ previousDays,
1063
+ todayEvents,
1064
+ myMrs,
1065
+ reviews,
1066
+ reviewPendingCount: reviews.filter((r) => !r.approvedByMe).length,
1067
+ blockers
1068
+ };
1069
+ }
1070
+
1071
+ export {
1072
+ STALE_DAYS,
1073
+ isoDay,
1074
+ localAt,
1075
+ label,
1076
+ previousActiveDays,
1077
+ classify,
1078
+ markMissingPipelines,
1079
+ ConfigError,
1080
+ GITLAB_LABELS,
1081
+ GITHUB_LABELS,
1082
+ resolveHost,
1083
+ resolveToken,
1084
+ parseLoggedInHosts,
1085
+ parseGlabHosts,
1086
+ glabHosts,
1087
+ glabToken,
1088
+ ghHosts,
1089
+ ghToken,
1090
+ postWebhook,
1091
+ buildUrl,
1092
+ ApiError,
1093
+ assertUsable,
1094
+ unreachable,
1095
+ extractErrors,
1096
+ repoFromUrl,
1097
+ mapEvent,
1098
+ normalizeChecks,
1099
+ latestStateByReviewer,
1100
+ countChangesRequested,
1101
+ approvedBy,
1102
+ GitHubProvider,
1103
+ GitLabProvider,
1104
+ chooseKind,
1105
+ connect,
1106
+ toMarkdown,
1107
+ buildReport
1108
+ };