standup-mr 0.1.2 → 0.2.1

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.
@@ -8,7 +8,7 @@ import { pathToFileURL } from "url";
8
8
  import { execFileSync } from "child_process";
9
9
 
10
10
  // src/config/config.constants.ts
11
- var GLAB_TIMEOUT_MS = 15e3;
11
+ var CLI_TIMEOUT_MS = 15e3;
12
12
 
13
13
  // src/config/config.ts
14
14
  var ConfigError = class extends Error {
@@ -17,51 +17,61 @@ var ConfigError = class extends Error {
17
17
  this.name = "ConfigError";
18
18
  }
19
19
  };
20
- function resolveHost(cliHost, envHost, glabHostList) {
20
+ var GITLAB_LABELS = {
21
+ name: "GitLab",
22
+ cli: "glab",
23
+ envHost: "GITLAB_HOST",
24
+ envToken: "GITLAB_TOKEN",
25
+ login: (host) => `glab auth login --hostname ${host}`
26
+ };
27
+ var GITHUB_LABELS = {
28
+ name: "GitHub",
29
+ cli: "gh",
30
+ envHost: "GITHUB_HOST",
31
+ envToken: "GITHUB_TOKEN",
32
+ login: (host) => `gh auth login --hostname ${host}`
33
+ };
34
+ function resolveHost(cliHost, envHost, hostList, labels = GITLAB_LABELS) {
21
35
  if (cliHost) return cliHost;
22
36
  if (envHost) return envHost;
23
- const hosts = glabHostList ?? [];
37
+ const hosts = hostList ?? [];
24
38
  if (hosts.length === 1) return hosts[0];
25
39
  if (hosts.length === 0) {
26
40
  throw new ConfigError(
27
- "No GitLab host configured. Pass --host, set GITLAB_HOST, or log in with `glab auth login`."
41
+ `No ${labels.name} host configured. Pass --host, set ${labels.envHost}, or log in with \`${labels.cli} auth login\`.`
28
42
  );
29
43
  }
30
44
  throw new ConfigError(
31
- `Multiple GitLab hosts found (${[...hosts].sort().join(", ")}). Pick one with --host or GITLAB_HOST.`
45
+ `Multiple ${labels.name} hosts found (${[...hosts].sort().join(", ")}). Pick one with --host or ${labels.envHost}.`
32
46
  );
33
47
  }
34
- function resolveToken(host, cliToken, envToken, glabLookup) {
48
+ function resolveToken(host, cliToken, envToken, lookup, labels = GITLAB_LABELS) {
35
49
  if (cliToken) return cliToken;
36
50
  if (envToken) return envToken;
37
- if (glabLookup) {
38
- const token = glabLookup(host);
51
+ if (lookup) {
52
+ const token = lookup(host);
39
53
  if (token) return token;
40
54
  }
41
55
  throw new ConfigError(
42
- `No token for ${host}. Pass --token, set GITLAB_TOKEN, or run \`glab auth login --hostname ${host}\`.`
56
+ `No token for ${host}. Pass --token, set ${labels.envToken}, or run \`${labels.login(host)}\`.`
43
57
  );
44
58
  }
45
- function glab(args) {
59
+ function cliCapture(cli, args) {
46
60
  try {
47
- return execFileSync("glab", args, {
61
+ return execFileSync(cli, args, {
48
62
  encoding: "utf8",
49
- timeout: GLAB_TIMEOUT_MS,
63
+ timeout: CLI_TIMEOUT_MS,
50
64
  stdio: ["ignore", "pipe", "ignore"]
51
65
  }).trim();
52
66
  } catch {
53
67
  return "";
54
68
  }
55
69
  }
56
- function parseGlabHosts(statusOutput) {
57
- const hosts = [...statusOutput.matchAll(/Logged in to (\S+)/g)].map((match) => match[1]);
58
- return [...new Set(hosts)].sort();
59
- }
60
- function glabStatus() {
70
+ function cliStatus(cli) {
61
71
  try {
62
- return execFileSync("glab", ["auth", "status"], {
72
+ return execFileSync(cli, ["auth", "status"], {
63
73
  encoding: "utf8",
64
- timeout: GLAB_TIMEOUT_MS,
74
+ timeout: CLI_TIMEOUT_MS,
65
75
  stdio: ["ignore", "pipe", "pipe"]
66
76
  });
67
77
  } catch (error) {
@@ -69,11 +79,21 @@ function glabStatus() {
69
79
  return `${result.stdout ?? ""}${result.stderr ?? ""}`;
70
80
  }
71
81
  }
82
+ function parseLoggedInHosts(statusOutput) {
83
+ const hosts = [...statusOutput.matchAll(/Logged in to (\S+)/g)].map((match) => match[1]);
84
+ return [...new Set(hosts)].sort();
85
+ }
72
86
  function glabHosts() {
73
- return parseGlabHosts(glabStatus());
87
+ return parseLoggedInHosts(cliStatus("glab"));
74
88
  }
75
89
  function glabToken(host) {
76
- return glab(["config", "get", "token", "--host", host]);
90
+ return cliCapture("glab", ["config", "get", "token", "--host", host]);
91
+ }
92
+ function ghHosts() {
93
+ return parseLoggedInHosts(cliStatus("gh"));
94
+ }
95
+ function ghToken(host) {
96
+ return cliCapture("gh", ["auth", "token", "--hostname", host]);
77
97
  }
78
98
 
79
99
  // src/buckets/buckets.constants.ts
@@ -123,6 +143,12 @@ function isoDay(day) {
123
143
  const date = String(day.getDate()).padStart(2, "0");
124
144
  return `${year}-${month}-${date}`;
125
145
  }
146
+ function localAt(iso) {
147
+ const at = new Date(iso);
148
+ const hours = String(at.getHours()).padStart(2, "0");
149
+ const minutes = String(at.getMinutes()).padStart(2, "0");
150
+ return `${isoDay(at)}T${hours}:${minutes}`;
151
+ }
126
152
  function label(day, lang = "en") {
127
153
  const key = DAYS[lang] ? lang : "en";
128
154
  const weekday = DAYS[key][day.getDay()];
@@ -130,21 +156,27 @@ function label(day, lang = "en") {
130
156
  const date = day.getDate();
131
157
  return key === "tr" ? `${date} ${month} ${weekday}` : `${weekday}, ${date} ${month}`;
132
158
  }
133
- function previousActiveDay(eventDates, today) {
159
+ function isWeekday(day) {
160
+ const weekday = (/* @__PURE__ */ new Date(`${day}T00:00:00`)).getDay();
161
+ return weekday >= 1 && weekday <= 5;
162
+ }
163
+ function previousActiveDays(eventDates, today) {
134
164
  const cutoff = isoDay(today);
135
- const past = [...eventDates].filter((d) => d < cutoff).sort();
136
- const latest = past[past.length - 1];
137
- if (!latest) return { date: null, gapDays: null };
138
- const gapDays = Math.round(
139
- (Date.parse(`${cutoff}T00:00:00Z`) - Date.parse(`${latest}T00:00:00Z`)) / MS_PER_DAY
140
- );
141
- return { date: latest, gapDays };
165
+ const past = [...eventDates].filter((day) => day < cutoff).sort();
166
+ if (past.length === 0) return [];
167
+ const anchor = past.filter(isWeekday).pop() ?? past[0];
168
+ return past.filter((day) => day >= anchor).map((day) => ({
169
+ date: day,
170
+ gapDays: Math.round(
171
+ (Date.parse(`${cutoff}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) / MS_PER_DAY
172
+ )
173
+ }));
142
174
  }
143
175
 
144
176
  // src/buckets/buckets.ts
145
177
  function classify(mr, today, staleDays = STALE_DAYS) {
146
178
  if (mr.draft) return "draft";
147
- if (mr.pipeline === "failed" || mr.unresolved > 0) return "blocked";
179
+ if (mr.pipeline === "failed" || mr.pipeline === "canceled" || mr.unresolved > 0) return "blocked";
148
180
  const age = Math.round(
149
181
  (Date.parse(`${isoDay(today)}T00:00:00Z`) - Date.parse(`${mr.updated}T00:00:00Z`)) / MS_PER_DAY
150
182
  );
@@ -163,6 +195,11 @@ var SECTION = /section_(start|end):\d+:\S*/g;
163
195
  var SIGNAL = /(\berror\b|Error:|fatal:|npm ERR!|\bfailed\b)/i;
164
196
  var NOISE = /^(Cleaning up|Job failed: exit status|ERROR: Job failed|Uploading artifacts|Job succeeded)/i;
165
197
  var MAX_LINE = 200;
198
+ var TIMESTAMP = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/;
199
+ var ERROR_MARKER = /^##\[error\]\s*/;
200
+ var GROUP_START = /^##\[group\]/;
201
+ var GROUP_END = /^##\[endgroup\]/;
202
+ var RUN_GROUP = /^##\[group\]Run\s/;
166
203
 
167
204
  // src/trace/trace.ts
168
205
  function extractErrors(rawTrace, limit = 8) {
@@ -170,9 +207,21 @@ function extractErrors(rawTrace, limit = 8) {
170
207
  const cleaned = rawTrace.replace(ANSI, "").replace(SECTION, "");
171
208
  const seen = /* @__PURE__ */ new Set();
172
209
  const hits = [];
210
+ let suppressing = false;
173
211
  for (const rawLine of cleaned.split("\n")) {
174
- const line = rawLine.trim();
175
- if (!line || NOISE.test(line) || !SIGNAL.test(line)) continue;
212
+ const stamped = rawLine.trim().replace(TIMESTAMP, "");
213
+ if (GROUP_START.test(stamped)) {
214
+ suppressing = RUN_GROUP.test(stamped);
215
+ continue;
216
+ }
217
+ if (GROUP_END.test(stamped)) {
218
+ suppressing = false;
219
+ continue;
220
+ }
221
+ if (suppressing) continue;
222
+ if (!stamped || NOISE.test(stamped) || !SIGNAL.test(stamped)) continue;
223
+ const line = stamped.replace(ERROR_MARKER, "").trim();
224
+ if (!line) continue;
176
225
  const clipped = line.slice(0, MAX_LINE);
177
226
  if (seen.has(clipped)) continue;
178
227
  seen.add(clipped);
@@ -181,64 +230,465 @@ function extractErrors(rawTrace, limit = 8) {
181
230
  return hits.slice(-limit);
182
231
  }
183
232
 
184
- // src/providers/gitlab/gitlab.constants.ts
233
+ // src/providers/base/http.ts
234
+ function buildUrl(api, path, params) {
235
+ const base = `${api}/${path}`;
236
+ if (!params || Object.keys(params).length === 0) return base;
237
+ const query = new URLSearchParams();
238
+ for (const [key, value] of Object.entries(params)) {
239
+ query.set(key, String(value));
240
+ }
241
+ return `${base}?${query.toString()}`;
242
+ }
243
+ var ApiError = class extends Error {
244
+ constructor(message) {
245
+ super(message);
246
+ this.name = "ApiError";
247
+ }
248
+ };
249
+ function remaining(response) {
250
+ return response.headers.get("x-ratelimit-remaining") ?? response.headers.get("ratelimit-remaining");
251
+ }
252
+ function resetAt(response) {
253
+ const reset = response.headers.get("x-ratelimit-reset") ?? response.headers.get("ratelimit-reset");
254
+ if (!reset) return "";
255
+ const seconds = Number(reset);
256
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
257
+ return `; resets at ${new Date(seconds * 1e3).toISOString()}`;
258
+ }
259
+ function retryAfter(response) {
260
+ const value = response.headers.get("retry-after");
261
+ if (!value) return "";
262
+ const seconds = Number(value);
263
+ if (!Number.isFinite(seconds) || seconds <= 0) return `; retry after ${value}`;
264
+ return `; retry after ${seconds}s`;
265
+ }
266
+ function assertUsable(response, host) {
267
+ if (response.ok || response.status === 404) return;
268
+ if (response.status === 403 || response.status === 429) {
269
+ if (remaining(response) === "0") {
270
+ throw new ApiError(`${host} rate limit reached${resetAt(response)}.`);
271
+ }
272
+ const retry = retryAfter(response);
273
+ if (retry) throw new ApiError(`${host} rate limit reached${retry}.`);
274
+ }
275
+ if (response.status === 401) {
276
+ throw new ApiError(
277
+ `${host} rejected the token (401). Check the token and its scopes.`
278
+ );
279
+ }
280
+ if (response.status === 403) {
281
+ throw new ApiError(
282
+ `${host} refused the request (403). The token has no access to that resource.`
283
+ );
284
+ }
285
+ throw new ApiError(`${host} returned ${response.status}.`);
286
+ }
287
+ function unreachable(host, cause) {
288
+ const detail = cause instanceof Error ? cause.message : String(cause);
289
+ return new ApiError(`Could not reach ${host}: ${detail}`);
290
+ }
291
+
292
+ // src/providers/github/github.constants.ts
185
293
  var PAGE_SIZE = 100;
186
294
  var FRESH_REVIEW_DAYS = 7;
295
+ var API_VERSION = "2022-11-28";
296
+ var DOT_COM = "github.com";
297
+ var SEARCH_CAP = 3;
298
+ var EVENT_FEED_CAP = 300;
299
+ var EVENTS_PAGE_CAP = EVENT_FEED_CAP / PAGE_SIZE;
300
+ var FAILED_CONCLUSIONS = /* @__PURE__ */ new Set([
301
+ "failure",
302
+ "timed_out",
303
+ "startup_failure",
304
+ "action_required"
305
+ ]);
306
+ var EVENT_ACTIONS = {
307
+ PushEvent: "pushed to",
308
+ PullRequestReviewEvent: "reviewed",
309
+ PullRequestReviewCommentEvent: "commented on",
310
+ IssueCommentEvent: "commented on",
311
+ CreateEvent: "created",
312
+ DeleteEvent: "deleted"
313
+ };
314
+ var EVENT_TARGET_TYPES = {
315
+ PullRequestEvent: "MergeRequest",
316
+ PullRequestReviewEvent: "MergeRequest",
317
+ PullRequestReviewCommentEvent: "MergeRequest",
318
+ IssueCommentEvent: "Note"
319
+ };
187
320
 
188
- // src/providers/gitlab/gitlab.ts
189
- var GitLabProvider = class {
321
+ // src/providers/github/github.map.ts
322
+ function repoFromUrl(repositoryUrl) {
323
+ const match = /\/repos\/([^/]+\/[^/]+)/.exec(repositoryUrl);
324
+ return match ? match[1] : "";
325
+ }
326
+ function mapEvent(raw) {
327
+ const payload = raw.payload ?? {};
328
+ const type = raw.type ?? "";
329
+ const commits = payload.commits ?? [];
330
+ const last = commits[commits.length - 1];
331
+ return {
332
+ at: localAt(raw.created_at),
333
+ action: type === "PullRequestEvent" ? String(payload.action ?? "updated") : EVENT_ACTIONS[type] ?? type.replace(/Event$/, "").toLowerCase(),
334
+ project: raw.repo?.name ?? "",
335
+ targetType: EVENT_TARGET_TYPES[type] ?? "",
336
+ title: payload.pull_request?.title ?? payload.issue?.title ?? "",
337
+ branch: String(payload.ref ?? "").replace(/^refs\/heads\//, ""),
338
+ commits: Number(payload.size ?? 0),
339
+ commitTitle: last ? String(last.message ?? "").split("\n")[0] : ""
340
+ };
341
+ }
342
+
343
+ // src/providers/github/github.state.ts
344
+ function normalizeChecks(runs) {
345
+ if (runs.length === 0) return { pipeline: null, pipelineId: null };
346
+ const failed = runs.find(
347
+ (run) => run.conclusion !== null && FAILED_CONCLUSIONS.has(run.conclusion)
348
+ );
349
+ if (failed) return { pipeline: "failed", pipelineId: failed.id };
350
+ if (runs.some((run) => run.status !== "completed")) {
351
+ return { pipeline: "running", pipelineId: null };
352
+ }
353
+ if (runs.some((run) => run.conclusion === "cancelled")) {
354
+ return { pipeline: "canceled", pipelineId: null };
355
+ }
356
+ return { pipeline: "success", pipelineId: null };
357
+ }
358
+ function latestStateByReviewer(reviews) {
359
+ const latest = /* @__PURE__ */ new Map();
360
+ for (const review of reviews) {
361
+ const login = review.user?.login;
362
+ if (!login) continue;
363
+ if (review.state === "COMMENTED" || review.state === "PENDING") continue;
364
+ latest.set(login, review.state);
365
+ }
366
+ return latest;
367
+ }
368
+ function countChangesRequested(reviews) {
369
+ return [...latestStateByReviewer(reviews).values()].filter(
370
+ (state) => state === "CHANGES_REQUESTED"
371
+ ).length;
372
+ }
373
+ function approvedBy(reviews, login) {
374
+ return latestStateByReviewer(reviews).get(login) === "APPROVED";
375
+ }
376
+
377
+ // src/providers/github/github.ts
378
+ var GitHubProvider = class {
379
+ kind = "github";
190
380
  host;
191
381
  api;
192
382
  token;
193
383
  fetchImpl;
384
+ identity = null;
194
385
  constructor(host, token, fetchImpl = fetch) {
195
386
  this.host = host;
387
+ this.api = host === DOT_COM ? "https://api.github.com" : `https://${host}/api/v3`;
196
388
  this.token = token;
197
- this.api = `https://${host}/api/v4`;
198
389
  this.fetchImpl = fetchImpl;
199
390
  }
200
- url(path, params) {
201
- const base = `${this.api}/${path}`;
202
- if (!params || Object.keys(params).length === 0) return base;
203
- const query = new URLSearchParams();
204
- for (const [key, value] of Object.entries(params)) {
205
- query.set(key, String(value));
391
+ headers() {
392
+ return {
393
+ authorization: `Bearer ${this.token}`,
394
+ accept: "application/vnd.github+json",
395
+ "x-github-api-version": API_VERSION
396
+ };
397
+ }
398
+ async send(url, init) {
399
+ try {
400
+ return await this.fetchImpl(url, { headers: this.headers(), ...init });
401
+ } catch (cause) {
402
+ throw unreachable(this.host, cause);
206
403
  }
207
- return `${base}?${query.toString()}`;
208
404
  }
209
405
  async getJson(path, params) {
406
+ const response = await this.send(buildUrl(this.api, path, params));
407
+ assertUsable(response, this.host);
408
+ if (!response.ok) return null;
210
409
  try {
211
- const response = await this.fetchImpl(this.url(path, params), {
212
- headers: { "PRIVATE-TOKEN": this.token }
213
- });
214
- if (!response.ok) return null;
215
410
  return await response.json();
216
- } catch {
217
- return null;
411
+ } catch (cause) {
412
+ throw unreachable(this.host, cause);
218
413
  }
219
414
  }
220
- async getText(path) {
415
+ async getPaged(path, params = {}, cap = 5) {
416
+ const rows = [];
417
+ for (let page = 1; page <= cap; page += 1) {
418
+ const chunk = await this.getJson(path, {
419
+ ...params,
420
+ per_page: PAGE_SIZE,
421
+ page
422
+ });
423
+ if (!chunk || chunk.length === 0) break;
424
+ rows.push(...chunk);
425
+ if (chunk.length < PAGE_SIZE) break;
426
+ }
427
+ return rows;
428
+ }
429
+ async getSearch(query, cap = 3) {
430
+ const rows = [];
431
+ for (let page = 1; page <= cap; page += 1) {
432
+ const chunk = await this.getJson("search/issues", {
433
+ q: query,
434
+ per_page: PAGE_SIZE,
435
+ page
436
+ });
437
+ const items = chunk?.items ?? [];
438
+ if (items.length === 0) break;
439
+ rows.push(...items);
440
+ if (items.length < PAGE_SIZE) break;
441
+ }
442
+ return rows;
443
+ }
444
+ async getLogText(path) {
445
+ const first = await this.send(buildUrl(this.api, path), { redirect: "manual" });
446
+ if (first.status === 404) return "";
447
+ const location = first.headers.get("location");
448
+ if (!location) {
449
+ if (first.status === 0 || first.status >= 300 && first.status < 400) return "";
450
+ assertUsable(first, this.host);
451
+ return first.ok ? await first.text() : "";
452
+ }
453
+ let blob;
454
+ try {
455
+ blob = await this.fetchImpl(location);
456
+ } catch (cause) {
457
+ throw unreachable(this.host, cause);
458
+ }
459
+ if (!blob.ok) return "";
460
+ return await blob.text();
461
+ }
462
+ async getIdentity() {
463
+ if (this.identity) return this.identity;
464
+ const me = await this.getJson("user");
465
+ if (!me) {
466
+ throw new ApiError(
467
+ `Could not read the ${this.host} user. Check the host, the token, and network access.`
468
+ );
469
+ }
470
+ this.identity = { id: me.id, username: me.login };
471
+ return this.identity;
472
+ }
473
+ async getEvents(since) {
474
+ const login = (await this.getIdentity()).username;
475
+ const raw = await this.getPaged(
476
+ `users/${encodeURIComponent(login)}/events`,
477
+ {},
478
+ EVENTS_PAGE_CAP
479
+ );
480
+ if (raw.length === 0) {
481
+ process.stderr.write(
482
+ `Warning: the ${this.host} events feed returned nothing. Private activity is only visible to a token that belongs to the same account.
483
+ `
484
+ );
485
+ }
486
+ const floor = isoDay(since);
487
+ return raw.map(mapEvent).filter((event) => event.at.slice(0, 10) >= floor).sort((a, b) => a.at.localeCompare(b.at));
488
+ }
489
+ async shapePr(item) {
490
+ const project = repoFromUrl(item.repository_url);
491
+ const iid = item.number;
492
+ const pull = await this.getJson(`repos/${project}/pulls/${iid}`);
493
+ if (!pull) return null;
494
+ const sha = pull.head?.sha ?? "";
495
+ const [checks, reviews] = await Promise.all([
496
+ sha ? this.getJson(
497
+ `repos/${project}/commits/${sha}/check-runs`,
498
+ { per_page: PAGE_SIZE }
499
+ ) : Promise.resolve(null),
500
+ this.getPaged(`repos/${project}/pulls/${iid}/reviews`, {}, 2)
501
+ ]);
502
+ const { pipeline, pipelineId } = normalizeChecks(checks?.check_runs ?? []);
503
+ return {
504
+ provider: "github",
505
+ project,
506
+ projectId: pull.base?.repo?.id ?? 0,
507
+ iid,
508
+ title: pull.title,
509
+ draft: pull.draft,
510
+ branch: pull.head?.ref ?? "",
511
+ target: pull.base?.ref ?? "",
512
+ updated: localAt(pull.updated_at).slice(0, 10),
513
+ url: pull.html_url,
514
+ mergeStatus: pull.mergeable_state ?? null,
515
+ pipeline,
516
+ pipelineId,
517
+ unresolved: countChangesRequested(reviews),
518
+ pipelineMissing: false,
519
+ bucket: "ready"
520
+ };
521
+ }
522
+ async getMyMrs(today) {
523
+ const login = (await this.getIdentity()).username;
524
+ const items = await this.getSearch(
525
+ `is:pr is:open author:${login} archived:false`,
526
+ SEARCH_CAP
527
+ );
528
+ const shaped = await Promise.all(items.map((item) => this.shapePr(item)));
529
+ const rows = shaped.filter((row) => row !== null);
530
+ markMissingPipelines(rows);
531
+ for (const row of rows) {
532
+ row.bucket = classify(row, today);
533
+ }
534
+ return rows;
535
+ }
536
+ async getReviews(identity, today) {
537
+ const items = await this.getSearch(
538
+ `is:pr is:open review-requested:${identity.username}`,
539
+ SEARCH_CAP
540
+ );
541
+ const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS * MS_PER_DAY));
542
+ const rows = await Promise.all(
543
+ items.map(async (item) => {
544
+ const project = repoFromUrl(item.repository_url);
545
+ const reviews = await this.getPaged(
546
+ `repos/${project}/pulls/${item.number}/reviews`,
547
+ {},
548
+ 2
549
+ );
550
+ const updated = localAt(item.updated_at).slice(0, 10);
551
+ return {
552
+ provider: "github",
553
+ project,
554
+ iid: item.number,
555
+ title: item.title,
556
+ author: item.user?.login ?? "",
557
+ updated,
558
+ draft: item.draft ?? false,
559
+ url: item.html_url,
560
+ fresh: updated >= cutoff,
561
+ approvedByMe: approvedBy(reviews, identity.username)
562
+ };
563
+ })
564
+ );
565
+ return rows.sort((a, b) => b.updated.localeCompare(a.updated));
566
+ }
567
+ async actionsBlocker(mr, sha) {
568
+ const runs = await this.getJson(
569
+ `repos/${mr.project}/actions/runs`,
570
+ { head_sha: sha, per_page: 10 }
571
+ );
572
+ const run = (runs?.workflow_runs ?? []).find(
573
+ (row) => row.conclusion !== null && FAILED_CONCLUSIONS.has(row.conclusion)
574
+ );
575
+ if (!run) return null;
576
+ const jobs = await this.getJson(
577
+ `repos/${mr.project}/actions/runs/${run.id}/jobs`,
578
+ { per_page: PAGE_SIZE }
579
+ );
580
+ const job = (jobs?.jobs ?? []).find(
581
+ (row) => row.conclusion !== null && FAILED_CONCLUSIONS.has(row.conclusion)
582
+ );
583
+ if (!job) return null;
584
+ const log = await this.getLogText(
585
+ `repos/${mr.project}/actions/jobs/${job.id}/logs`
586
+ );
587
+ return {
588
+ provider: "github",
589
+ project: mr.project,
590
+ mr: mr.iid,
591
+ title: mr.title,
592
+ job: job.name,
593
+ stage: run.name ?? "",
594
+ url: mr.url,
595
+ errors: extractErrors(log)
596
+ };
597
+ }
598
+ async checkRunBlocker(mr, sha) {
599
+ const checks = await this.getJson(
600
+ `repos/${mr.project}/commits/${sha}/check-runs`,
601
+ { per_page: PAGE_SIZE }
602
+ );
603
+ const failed = (checks?.check_runs ?? []).find(
604
+ (run) => run.conclusion !== null && FAILED_CONCLUSIONS.has(run.conclusion)
605
+ );
606
+ if (!failed) return null;
607
+ const summary = [failed.output?.summary ?? "", failed.output?.text ?? ""].filter(Boolean).join("\n");
608
+ const errors = extractErrors(summary);
609
+ if (errors.length === 0) return null;
610
+ return {
611
+ provider: "github",
612
+ project: mr.project,
613
+ mr: mr.iid,
614
+ title: mr.title,
615
+ job: failed.name,
616
+ stage: failed.app?.slug ?? "check",
617
+ url: mr.url,
618
+ errors
619
+ };
620
+ }
621
+ async diagnose(mr) {
622
+ const pull = await this.getJson(
623
+ `repos/${mr.project}/pulls/${mr.iid}`
624
+ );
625
+ const sha = pull?.head?.sha;
626
+ if (!sha) return null;
627
+ const actions = await this.actionsBlocker(mr, sha);
628
+ if (actions?.errors.length) return actions;
629
+ return await this.checkRunBlocker(mr, sha) ?? actions;
630
+ }
631
+ async getBlockers(mrs) {
632
+ const red = mrs.filter((mr) => mr.pipeline === "failed");
633
+ const diagnosed = await Promise.all(red.map((mr) => this.diagnose(mr)));
634
+ return diagnosed.filter((row) => row !== null);
635
+ }
636
+ };
637
+
638
+ // src/providers/gitlab/gitlab.constants.ts
639
+ var PAGE_SIZE2 = 100;
640
+ var FRESH_REVIEW_DAYS2 = 7;
641
+
642
+ // src/providers/gitlab/gitlab.ts
643
+ var GitLabProvider = class {
644
+ kind = "gitlab";
645
+ host;
646
+ api;
647
+ token;
648
+ fetchImpl;
649
+ constructor(host, token, fetchImpl = fetch) {
650
+ this.host = host;
651
+ this.token = token;
652
+ this.api = `https://${host}/api/v4`;
653
+ this.fetchImpl = fetchImpl;
654
+ }
655
+ async send(url) {
656
+ let response;
221
657
  try {
222
- const response = await this.fetchImpl(this.url(path), {
658
+ response = await this.fetchImpl(url, {
223
659
  headers: { "PRIVATE-TOKEN": this.token }
224
660
  });
225
- if (!response.ok) return "";
226
- return await response.text();
227
- } catch {
228
- return "";
661
+ } catch (cause) {
662
+ throw unreachable(this.host, cause);
229
663
  }
664
+ assertUsable(response, this.host);
665
+ return response;
666
+ }
667
+ async getJson(path, params) {
668
+ const response = await this.send(buildUrl(this.api, path, params));
669
+ if (!response.ok) return null;
670
+ try {
671
+ return await response.json();
672
+ } catch (cause) {
673
+ throw unreachable(this.host, cause);
674
+ }
675
+ }
676
+ async getText(path) {
677
+ const response = await this.send(buildUrl(this.api, path));
678
+ if (!response.ok) return "";
679
+ return await response.text();
230
680
  }
231
681
  async getPaged(path, params = {}, cap = 5) {
232
682
  const rows = [];
233
683
  for (let page = 1; page <= cap; page += 1) {
234
684
  const chunk = await this.getJson(path, {
235
685
  ...params,
236
- per_page: PAGE_SIZE,
686
+ per_page: PAGE_SIZE2,
237
687
  page
238
688
  });
239
689
  if (!chunk || chunk.length === 0) break;
240
690
  rows.push(...chunk);
241
- if (chunk.length < PAGE_SIZE) break;
691
+ if (chunk.length < PAGE_SIZE2) break;
242
692
  }
243
693
  return rows;
244
694
  }
@@ -284,7 +734,7 @@ var GitLabProvider = class {
284
734
  async countUnresolved(projectId, iid) {
285
735
  const discussions = await this.getJson(
286
736
  `projects/${projectId}/merge_requests/${iid}/discussions`,
287
- { per_page: PAGE_SIZE }
737
+ { per_page: PAGE_SIZE2 }
288
738
  );
289
739
  if (!discussions) return 0;
290
740
  return discussions.filter((discussion) => {
@@ -304,6 +754,7 @@ var GitLabProvider = class {
304
754
  ]);
305
755
  const latest = pipelines?.[0];
306
756
  return {
757
+ provider: "gitlab",
307
758
  project: String(mr.references.full).split("!")[0],
308
759
  projectId,
309
760
  iid,
@@ -338,15 +789,16 @@ var GitLabProvider = class {
338
789
  if (!approvals) return false;
339
790
  return (approvals.approved_by ?? []).some((entry2) => entry2.user?.id === uid);
340
791
  }
341
- async getReviews(uid, today) {
792
+ async getReviews(identity, today) {
342
793
  const raw = await this.getPaged("merge_requests", {
343
794
  scope: "all",
344
795
  state: "opened",
345
- reviewer_id: uid
796
+ reviewer_id: identity.id
346
797
  });
347
- const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS * MS_PER_DAY));
798
+ const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS2 * MS_PER_DAY));
348
799
  const rows = await Promise.all(
349
800
  raw.map(async (mr) => ({
801
+ provider: "gitlab",
350
802
  project: String(mr.references.full).split("!")[0],
351
803
  iid: mr.iid,
352
804
  title: mr.title,
@@ -355,7 +807,7 @@ var GitLabProvider = class {
355
807
  draft: mr.draft,
356
808
  url: mr.web_url,
357
809
  fresh: String(mr.updated_at).slice(0, 10) >= cutoff,
358
- approvedByMe: await this.approvedByMe(mr.project_id, mr.iid, uid)
810
+ approvedByMe: await this.approvedByMe(mr.project_id, mr.iid, identity.id)
359
811
  }))
360
812
  );
361
813
  return rows.sort((a, b) => b.updated.localeCompare(a.updated));
@@ -366,7 +818,7 @@ var GitLabProvider = class {
366
818
  red.map(async (mr) => {
367
819
  const jobs = await this.getJson(
368
820
  `projects/${mr.projectId}/pipelines/${mr.pipelineId}/jobs`,
369
- { per_page: PAGE_SIZE }
821
+ { per_page: PAGE_SIZE2 }
370
822
  ) ?? [];
371
823
  const job = jobs.find((j) => j.status === "failed");
372
824
  if (!job) return null;
@@ -374,6 +826,7 @@ var GitLabProvider = class {
374
826
  `projects/${mr.projectId}/jobs/${job.id}/trace`
375
827
  );
376
828
  return {
829
+ provider: "gitlab",
377
830
  project: mr.project,
378
831
  mr: mr.iid,
379
832
  title: mr.title,
@@ -388,6 +841,86 @@ var GitLabProvider = class {
388
841
  }
389
842
  };
390
843
 
844
+ // src/providers/select.ts
845
+ var DEFAULT_PROBE = { gitlab: glabHosts, github: ghHosts };
846
+ function hostFrom(cliHost, envHost, probe, fallback, labels) {
847
+ if (cliHost) return cliHost;
848
+ if (envHost) return envHost;
849
+ const found = probe();
850
+ return resolveHost(void 0, void 0, found.length > 0 ? found : fallback, labels);
851
+ }
852
+ function chooseKind(options = {}) {
853
+ const env = options.env ?? process.env;
854
+ const probe = options.probe ?? DEFAULT_PROBE;
855
+ if (options.provider) {
856
+ if (options.provider === "github" || options.provider === "gitlab") {
857
+ return options.provider;
858
+ }
859
+ throw new ConfigError(
860
+ `Unknown provider "${options.provider}" for --provider. Use github or gitlab.`
861
+ );
862
+ }
863
+ if (options.host) {
864
+ const host = options.host.toLowerCase();
865
+ if (host === DOT_COM || host.includes("github")) return "github";
866
+ if (host === "gitlab.com" || host.includes("gitlab")) return "gitlab";
867
+ }
868
+ if (env.STANDUP_PROVIDER) {
869
+ if (env.STANDUP_PROVIDER === "github" || env.STANDUP_PROVIDER === "gitlab") {
870
+ return env.STANDUP_PROVIDER;
871
+ }
872
+ throw new ConfigError(
873
+ `Unknown provider "${env.STANDUP_PROVIDER}" for STANDUP_PROVIDER. Use github or gitlab.`
874
+ );
875
+ }
876
+ const hasGitHubEnv = Boolean(env.GITHUB_HOST || env.GITHUB_TOKEN);
877
+ const hasGitLabEnv = Boolean(env.GITLAB_HOST || env.GITLAB_TOKEN);
878
+ if (hasGitHubEnv !== hasGitLabEnv) return hasGitHubEnv ? "github" : "gitlab";
879
+ if (hasGitHubEnv && hasGitLabEnv) {
880
+ const ghHost = Boolean(env.GITHUB_HOST);
881
+ const glabHost = Boolean(env.GITLAB_HOST);
882
+ if (ghHost !== glabHost) return ghHost ? "github" : "gitlab";
883
+ throw new ConfigError(
884
+ "Both GITHUB_* and GITLAB_* are configured. Pass --provider github or --provider gitlab."
885
+ );
886
+ }
887
+ const ghLoggedIn = probe.github().length > 0;
888
+ const glabLoggedIn = probe.gitlab().length > 0;
889
+ if (ghLoggedIn !== glabLoggedIn) return ghLoggedIn ? "github" : "gitlab";
890
+ if (ghLoggedIn && glabLoggedIn) {
891
+ throw new ConfigError(
892
+ "Both gh and glab are authenticated. Pass --provider github or --provider gitlab."
893
+ );
894
+ }
895
+ throw new ConfigError(
896
+ "No provider configured. Pass --provider with --host and --token, set GITHUB_* or GITLAB_* environment variables, or log in with gh or glab."
897
+ );
898
+ }
899
+ function connect(options = {}) {
900
+ const env = options.env ?? process.env;
901
+ const probe = options.probe ?? DEFAULT_PROBE;
902
+ if (chooseKind(options) === "github") {
903
+ const host2 = hostFrom(options.host, env.GITHUB_HOST, probe.github, [DOT_COM], GITHUB_LABELS);
904
+ const token2 = resolveToken(
905
+ host2,
906
+ options.token,
907
+ env.GITHUB_TOKEN,
908
+ ghToken,
909
+ GITHUB_LABELS
910
+ );
911
+ return new GitHubProvider(host2, token2);
912
+ }
913
+ const host = hostFrom(options.host, env.GITLAB_HOST, probe.gitlab, [], GITLAB_LABELS);
914
+ const token = resolveToken(
915
+ host,
916
+ options.token,
917
+ env.GITLAB_TOKEN,
918
+ glabToken,
919
+ GITLAB_LABELS
920
+ );
921
+ return new GitLabProvider(host, token);
922
+ }
923
+
391
924
  // src/report/report.constants.ts
392
925
  var LOOKBACK_DAYS = 21;
393
926
 
@@ -396,27 +929,27 @@ async function buildReport(provider, today, lang = "en", lookbackDays = LOOKBACK
396
929
  const identity = await provider.getIdentity();
397
930
  const since = new Date(today.getTime() - lookbackDays * MS_PER_DAY);
398
931
  const events = await provider.getEvents(since);
399
- const { date: previousDate, gapDays } = previousActiveDay(
932
+ const days = previousActiveDays(
400
933
  new Set(events.map((e) => e.at.slice(0, 10))),
401
934
  today
402
935
  );
403
- const previousEvents = previousDate ? events.filter((e) => e.at.slice(0, 10) === previousDate) : [];
936
+ const previousDays = days.map(({ date, gapDays }) => ({
937
+ date,
938
+ label: label(/* @__PURE__ */ new Date(`${date}T00:00:00`), lang),
939
+ gapDays,
940
+ events: events.filter((e) => e.at.slice(0, 10) === date)
941
+ }));
404
942
  const todayEvents = events.filter((e) => e.at.slice(0, 10) === isoDay(today));
405
943
  const myMrs = await provider.getMyMrs(today);
406
944
  const [reviews, blockers] = await Promise.all([
407
- provider.getReviews(identity.id, today),
945
+ provider.getReviews(identity, today),
408
946
  provider.getBlockers(myMrs)
409
947
  ]);
410
948
  return {
949
+ provider: provider.kind,
411
950
  user: identity.username,
412
951
  today: { date: isoDay(today), label: label(today, lang) },
413
- previous: {
414
- date: previousDate,
415
- label: previousDate ? label(/* @__PURE__ */ new Date(`${previousDate}T00:00:00`), lang) : null,
416
- gapDays,
417
- eventCount: previousEvents.length
418
- },
419
- previousEvents,
952
+ previousDays,
420
953
  todayEvents,
421
954
  myMrs,
422
955
  reviews,
@@ -426,22 +959,17 @@ async function buildReport(provider, today, lang = "en", lookbackDays = LOOKBACK
426
959
  }
427
960
 
428
961
  // mcp/server.ts
429
- function resolveProvider(options) {
430
- if (options.provider) return options.provider;
431
- const host = resolveHost(options.host, process.env.GITLAB_HOST, glabHosts());
432
- const token = resolveToken(host, options.token, process.env.GITLAB_TOKEN, glabToken);
433
- return new GitLabProvider(host, token);
434
- }
435
962
  async function collect(options = {}) {
436
- return buildReport(resolveProvider(options), /* @__PURE__ */ new Date(), options.lang ?? "en");
963
+ const provider = options.providerImpl ?? connect({ provider: options.provider, host: options.host, token: options.token });
964
+ return buildReport(provider, /* @__PURE__ */ new Date(), options.lang ?? "en");
437
965
  }
438
966
  async function main() {
439
967
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
440
968
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
441
- const server = new McpServer({ name: "standup-mr", version: "0.1.0" });
969
+ const server = new McpServer({ name: "standup-mr", version: "0.2.1" });
442
970
  server.tool(
443
971
  "get_standup_data",
444
- "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.",
972
+ "Collect merge-request-based standup data from GitLab or GitHub. Returns the previous working day activity, open merge requests or pull requests bucketed by state (ready / blocked / draft / stale), pending reviews, and the error lines from any failed pipeline or check.",
445
973
  {},
446
974
  async () => {
447
975
  const report = await collect();