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.
@@ -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,15 +156,21 @@ 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
@@ -163,6 +195,9 @@ 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 = /^##\[(group|endgroup)\]/;
166
201
 
167
202
  // src/trace/trace.ts
168
203
  function extractErrors(rawTrace, limit = 8) {
@@ -171,8 +206,11 @@ function extractErrors(rawTrace, limit = 8) {
171
206
  const seen = /* @__PURE__ */ new Set();
172
207
  const hits = [];
173
208
  for (const rawLine of cleaned.split("\n")) {
174
- const line = rawLine.trim();
175
- if (!line || NOISE.test(line) || !SIGNAL.test(line)) continue;
209
+ const stamped = rawLine.trim().replace(TIMESTAMP, "");
210
+ if (GROUP.test(stamped)) continue;
211
+ if (!stamped || NOISE.test(stamped) || !SIGNAL.test(stamped)) continue;
212
+ const line = stamped.replace(ERROR_MARKER, "").trim();
213
+ if (!line) continue;
176
214
  const clipped = line.slice(0, MAX_LINE);
177
215
  if (seen.has(clipped)) continue;
178
216
  seen.add(clipped);
@@ -181,64 +219,465 @@ function extractErrors(rawTrace, limit = 8) {
181
219
  return hits.slice(-limit);
182
220
  }
183
221
 
184
- // src/providers/gitlab/gitlab.constants.ts
222
+ // src/providers/base/http.ts
223
+ function buildUrl(api, path, params) {
224
+ const base = `${api}/${path}`;
225
+ if (!params || Object.keys(params).length === 0) return base;
226
+ const query = new URLSearchParams();
227
+ for (const [key, value] of Object.entries(params)) {
228
+ query.set(key, String(value));
229
+ }
230
+ return `${base}?${query.toString()}`;
231
+ }
232
+ var ApiError = class extends Error {
233
+ constructor(message) {
234
+ super(message);
235
+ this.name = "ApiError";
236
+ }
237
+ };
238
+ function remaining(response) {
239
+ return response.headers.get("x-ratelimit-remaining") ?? response.headers.get("ratelimit-remaining");
240
+ }
241
+ function resetAt(response) {
242
+ const reset = response.headers.get("x-ratelimit-reset") ?? response.headers.get("ratelimit-reset");
243
+ if (!reset) return "";
244
+ const seconds = Number(reset);
245
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
246
+ return `; resets at ${new Date(seconds * 1e3).toISOString()}`;
247
+ }
248
+ function retryAfter(response) {
249
+ const value = response.headers.get("retry-after");
250
+ if (!value) return "";
251
+ const seconds = Number(value);
252
+ if (!Number.isFinite(seconds) || seconds <= 0) return `; retry after ${value}`;
253
+ return `; retry after ${seconds}s`;
254
+ }
255
+ function assertUsable(response, host) {
256
+ if (response.ok || response.status === 404) return;
257
+ if (response.status === 403 || response.status === 429) {
258
+ if (remaining(response) === "0") {
259
+ throw new ApiError(`${host} rate limit reached${resetAt(response)}.`);
260
+ }
261
+ const retry = retryAfter(response);
262
+ if (retry) throw new ApiError(`${host} rate limit reached${retry}.`);
263
+ }
264
+ if (response.status === 401) {
265
+ throw new ApiError(
266
+ `${host} rejected the token (401). Check the token and its scopes.`
267
+ );
268
+ }
269
+ if (response.status === 403) {
270
+ throw new ApiError(
271
+ `${host} refused the request (403). The token has no access to that resource.`
272
+ );
273
+ }
274
+ throw new ApiError(`${host} returned ${response.status}.`);
275
+ }
276
+ function unreachable(host, cause) {
277
+ const detail = cause instanceof Error ? cause.message : String(cause);
278
+ return new ApiError(`Could not reach ${host}: ${detail}`);
279
+ }
280
+
281
+ // src/providers/github/github.constants.ts
185
282
  var PAGE_SIZE = 100;
186
283
  var FRESH_REVIEW_DAYS = 7;
284
+ var API_VERSION = "2022-11-28";
285
+ var DOT_COM = "github.com";
286
+ var SEARCH_CAP = 3;
287
+ var EVENT_FEED_CAP = 300;
288
+ var EVENTS_PAGE_CAP = EVENT_FEED_CAP / PAGE_SIZE;
289
+ var FAILED_CONCLUSIONS = /* @__PURE__ */ new Set([
290
+ "failure",
291
+ "timed_out",
292
+ "startup_failure",
293
+ "action_required"
294
+ ]);
295
+ var EVENT_ACTIONS = {
296
+ PushEvent: "pushed to",
297
+ PullRequestReviewEvent: "reviewed",
298
+ PullRequestReviewCommentEvent: "commented on",
299
+ IssueCommentEvent: "commented on",
300
+ CreateEvent: "created",
301
+ DeleteEvent: "deleted"
302
+ };
303
+ var EVENT_TARGET_TYPES = {
304
+ PullRequestEvent: "MergeRequest",
305
+ PullRequestReviewEvent: "MergeRequest",
306
+ PullRequestReviewCommentEvent: "MergeRequest",
307
+ IssueCommentEvent: "Note"
308
+ };
187
309
 
188
- // src/providers/gitlab/gitlab.ts
189
- var GitLabProvider = class {
310
+ // src/providers/github/github.map.ts
311
+ function repoFromUrl(repositoryUrl) {
312
+ const match = /\/repos\/([^/]+\/[^/]+)/.exec(repositoryUrl);
313
+ return match ? match[1] : "";
314
+ }
315
+ function mapEvent(raw) {
316
+ const payload = raw.payload ?? {};
317
+ const type = raw.type ?? "";
318
+ const commits = payload.commits ?? [];
319
+ const last = commits[commits.length - 1];
320
+ return {
321
+ at: localAt(raw.created_at),
322
+ action: type === "PullRequestEvent" ? String(payload.action ?? "updated") : EVENT_ACTIONS[type] ?? type.replace(/Event$/, "").toLowerCase(),
323
+ project: raw.repo?.name ?? "",
324
+ targetType: EVENT_TARGET_TYPES[type] ?? "",
325
+ title: payload.pull_request?.title ?? payload.issue?.title ?? "",
326
+ branch: String(payload.ref ?? "").replace(/^refs\/heads\//, ""),
327
+ commits: Number(payload.size ?? 0),
328
+ commitTitle: last ? String(last.message ?? "").split("\n")[0] : ""
329
+ };
330
+ }
331
+
332
+ // src/providers/github/github.state.ts
333
+ function normalizeChecks(runs) {
334
+ if (runs.length === 0) return { pipeline: null, pipelineId: null };
335
+ const failed = runs.find(
336
+ (run) => run.conclusion !== null && FAILED_CONCLUSIONS.has(run.conclusion)
337
+ );
338
+ if (failed) return { pipeline: "failed", pipelineId: failed.id };
339
+ if (runs.some((run) => run.status !== "completed")) {
340
+ return { pipeline: "running", pipelineId: null };
341
+ }
342
+ if (runs.some((run) => run.conclusion === "cancelled")) {
343
+ return { pipeline: "canceled", pipelineId: null };
344
+ }
345
+ return { pipeline: "success", pipelineId: null };
346
+ }
347
+ function latestStateByReviewer(reviews) {
348
+ const latest = /* @__PURE__ */ new Map();
349
+ for (const review of reviews) {
350
+ const login = review.user?.login;
351
+ if (!login) continue;
352
+ if (review.state === "COMMENTED" || review.state === "PENDING") continue;
353
+ latest.set(login, review.state);
354
+ }
355
+ return latest;
356
+ }
357
+ function countChangesRequested(reviews) {
358
+ return [...latestStateByReviewer(reviews).values()].filter(
359
+ (state) => state === "CHANGES_REQUESTED"
360
+ ).length;
361
+ }
362
+ function approvedBy(reviews, login) {
363
+ return latestStateByReviewer(reviews).get(login) === "APPROVED";
364
+ }
365
+
366
+ // src/providers/github/github.ts
367
+ var GitHubProvider = class {
368
+ kind = "github";
190
369
  host;
191
370
  api;
192
371
  token;
193
372
  fetchImpl;
373
+ identity = null;
194
374
  constructor(host, token, fetchImpl = fetch) {
195
375
  this.host = host;
376
+ this.api = host === DOT_COM ? "https://api.github.com" : `https://${host}/api/v3`;
196
377
  this.token = token;
197
- this.api = `https://${host}/api/v4`;
198
378
  this.fetchImpl = fetchImpl;
199
379
  }
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));
380
+ headers() {
381
+ return {
382
+ authorization: `Bearer ${this.token}`,
383
+ accept: "application/vnd.github+json",
384
+ "x-github-api-version": API_VERSION
385
+ };
386
+ }
387
+ async send(url, init) {
388
+ try {
389
+ return await this.fetchImpl(url, { headers: this.headers(), ...init });
390
+ } catch (cause) {
391
+ throw unreachable(this.host, cause);
206
392
  }
207
- return `${base}?${query.toString()}`;
208
393
  }
209
394
  async getJson(path, params) {
395
+ const response = await this.send(buildUrl(this.api, path, params));
396
+ assertUsable(response, this.host);
397
+ if (!response.ok) return null;
210
398
  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
399
  return await response.json();
216
- } catch {
217
- return null;
400
+ } catch (cause) {
401
+ throw unreachable(this.host, cause);
218
402
  }
219
403
  }
220
- async getText(path) {
404
+ async getPaged(path, params = {}, cap = 5) {
405
+ const rows = [];
406
+ for (let page = 1; page <= cap; page += 1) {
407
+ const chunk = await this.getJson(path, {
408
+ ...params,
409
+ per_page: PAGE_SIZE,
410
+ page
411
+ });
412
+ if (!chunk || chunk.length === 0) break;
413
+ rows.push(...chunk);
414
+ if (chunk.length < PAGE_SIZE) break;
415
+ }
416
+ return rows;
417
+ }
418
+ async getSearch(query, cap = 3) {
419
+ const rows = [];
420
+ for (let page = 1; page <= cap; page += 1) {
421
+ const chunk = await this.getJson("search/issues", {
422
+ q: query,
423
+ per_page: PAGE_SIZE,
424
+ page
425
+ });
426
+ const items = chunk?.items ?? [];
427
+ if (items.length === 0) break;
428
+ rows.push(...items);
429
+ if (items.length < PAGE_SIZE) break;
430
+ }
431
+ return rows;
432
+ }
433
+ async getLogText(path) {
434
+ const first = await this.send(buildUrl(this.api, path), { redirect: "manual" });
435
+ if (first.status === 404) return "";
436
+ const location = first.headers.get("location");
437
+ if (!location) {
438
+ if (first.status === 0 || first.status >= 300 && first.status < 400) return "";
439
+ assertUsable(first, this.host);
440
+ return first.ok ? await first.text() : "";
441
+ }
442
+ let blob;
443
+ try {
444
+ blob = await this.fetchImpl(location);
445
+ } catch (cause) {
446
+ throw unreachable(this.host, cause);
447
+ }
448
+ if (!blob.ok) return "";
449
+ return await blob.text();
450
+ }
451
+ async getIdentity() {
452
+ if (this.identity) return this.identity;
453
+ const me = await this.getJson("user");
454
+ if (!me) {
455
+ throw new ApiError(
456
+ `Could not read the ${this.host} user. Check the host, the token, and network access.`
457
+ );
458
+ }
459
+ this.identity = { id: me.id, username: me.login };
460
+ return this.identity;
461
+ }
462
+ async getEvents(since) {
463
+ const login = (await this.getIdentity()).username;
464
+ const raw = await this.getPaged(
465
+ `users/${encodeURIComponent(login)}/events`,
466
+ {},
467
+ EVENTS_PAGE_CAP
468
+ );
469
+ if (raw.length === 0) {
470
+ process.stderr.write(
471
+ `Warning: the ${this.host} events feed returned nothing. Private activity is only visible to a token that belongs to the same account.
472
+ `
473
+ );
474
+ }
475
+ const floor = isoDay(since);
476
+ return raw.map(mapEvent).filter((event) => event.at.slice(0, 10) >= floor).sort((a, b) => a.at.localeCompare(b.at));
477
+ }
478
+ async shapePr(item) {
479
+ const project = repoFromUrl(item.repository_url);
480
+ const iid = item.number;
481
+ const pull = await this.getJson(`repos/${project}/pulls/${iid}`);
482
+ if (!pull) return null;
483
+ const sha = pull.head?.sha ?? "";
484
+ const [checks, reviews] = await Promise.all([
485
+ sha ? this.getJson(
486
+ `repos/${project}/commits/${sha}/check-runs`,
487
+ { per_page: PAGE_SIZE }
488
+ ) : Promise.resolve(null),
489
+ this.getPaged(`repos/${project}/pulls/${iid}/reviews`, {}, 2)
490
+ ]);
491
+ const { pipeline, pipelineId } = normalizeChecks(checks?.check_runs ?? []);
492
+ return {
493
+ provider: "github",
494
+ project,
495
+ projectId: pull.base?.repo?.id ?? 0,
496
+ iid,
497
+ title: pull.title,
498
+ draft: pull.draft,
499
+ branch: pull.head?.ref ?? "",
500
+ target: pull.base?.ref ?? "",
501
+ updated: localAt(pull.updated_at).slice(0, 10),
502
+ url: pull.html_url,
503
+ mergeStatus: pull.mergeable_state ?? null,
504
+ pipeline,
505
+ pipelineId,
506
+ unresolved: countChangesRequested(reviews),
507
+ pipelineMissing: false,
508
+ bucket: "ready"
509
+ };
510
+ }
511
+ async getMyMrs(today) {
512
+ const login = (await this.getIdentity()).username;
513
+ const items = await this.getSearch(
514
+ `is:pr is:open author:${login} archived:false`,
515
+ SEARCH_CAP
516
+ );
517
+ const shaped = await Promise.all(items.map((item) => this.shapePr(item)));
518
+ const rows = shaped.filter((row) => row !== null);
519
+ markMissingPipelines(rows);
520
+ for (const row of rows) {
521
+ row.bucket = classify(row, today);
522
+ }
523
+ return rows;
524
+ }
525
+ async getReviews(identity, today) {
526
+ const items = await this.getSearch(
527
+ `is:pr is:open review-requested:${identity.username}`,
528
+ SEARCH_CAP
529
+ );
530
+ const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS * MS_PER_DAY));
531
+ const rows = await Promise.all(
532
+ items.map(async (item) => {
533
+ const project = repoFromUrl(item.repository_url);
534
+ const reviews = await this.getPaged(
535
+ `repos/${project}/pulls/${item.number}/reviews`,
536
+ {},
537
+ 2
538
+ );
539
+ const updated = localAt(item.updated_at).slice(0, 10);
540
+ return {
541
+ provider: "github",
542
+ project,
543
+ iid: item.number,
544
+ title: item.title,
545
+ author: item.user?.login ?? "",
546
+ updated,
547
+ draft: item.draft ?? false,
548
+ url: item.html_url,
549
+ fresh: updated >= cutoff,
550
+ approvedByMe: approvedBy(reviews, identity.username)
551
+ };
552
+ })
553
+ );
554
+ return rows.sort((a, b) => b.updated.localeCompare(a.updated));
555
+ }
556
+ async actionsBlocker(mr, sha) {
557
+ const runs = await this.getJson(
558
+ `repos/${mr.project}/actions/runs`,
559
+ { head_sha: sha, per_page: 10 }
560
+ );
561
+ const run = (runs?.workflow_runs ?? []).find(
562
+ (row) => row.conclusion !== null && FAILED_CONCLUSIONS.has(row.conclusion)
563
+ );
564
+ if (!run) return null;
565
+ const jobs = await this.getJson(
566
+ `repos/${mr.project}/actions/runs/${run.id}/jobs`,
567
+ { per_page: PAGE_SIZE }
568
+ );
569
+ const job = (jobs?.jobs ?? []).find(
570
+ (row) => row.conclusion !== null && FAILED_CONCLUSIONS.has(row.conclusion)
571
+ );
572
+ if (!job) return null;
573
+ const log = await this.getLogText(
574
+ `repos/${mr.project}/actions/jobs/${job.id}/logs`
575
+ );
576
+ return {
577
+ provider: "github",
578
+ project: mr.project,
579
+ mr: mr.iid,
580
+ title: mr.title,
581
+ job: job.name,
582
+ stage: run.name ?? "",
583
+ url: mr.url,
584
+ errors: extractErrors(log)
585
+ };
586
+ }
587
+ async checkRunBlocker(mr, sha) {
588
+ const checks = await this.getJson(
589
+ `repos/${mr.project}/commits/${sha}/check-runs`,
590
+ { per_page: PAGE_SIZE }
591
+ );
592
+ const failed = (checks?.check_runs ?? []).find(
593
+ (run) => run.conclusion !== null && FAILED_CONCLUSIONS.has(run.conclusion)
594
+ );
595
+ if (!failed) return null;
596
+ const summary = [failed.output?.summary ?? "", failed.output?.text ?? ""].filter(Boolean).join("\n");
597
+ const errors = extractErrors(summary);
598
+ if (errors.length === 0) return null;
599
+ return {
600
+ provider: "github",
601
+ project: mr.project,
602
+ mr: mr.iid,
603
+ title: mr.title,
604
+ job: failed.name,
605
+ stage: failed.app?.slug ?? "check",
606
+ url: mr.url,
607
+ errors
608
+ };
609
+ }
610
+ async diagnose(mr) {
611
+ const pull = await this.getJson(
612
+ `repos/${mr.project}/pulls/${mr.iid}`
613
+ );
614
+ const sha = pull?.head?.sha;
615
+ if (!sha) return null;
616
+ const actions = await this.actionsBlocker(mr, sha);
617
+ if (actions?.errors.length) return actions;
618
+ return await this.checkRunBlocker(mr, sha) ?? actions;
619
+ }
620
+ async getBlockers(mrs) {
621
+ const red = mrs.filter((mr) => mr.pipeline === "failed");
622
+ const diagnosed = await Promise.all(red.map((mr) => this.diagnose(mr)));
623
+ return diagnosed.filter((row) => row !== null);
624
+ }
625
+ };
626
+
627
+ // src/providers/gitlab/gitlab.constants.ts
628
+ var PAGE_SIZE2 = 100;
629
+ var FRESH_REVIEW_DAYS2 = 7;
630
+
631
+ // src/providers/gitlab/gitlab.ts
632
+ var GitLabProvider = class {
633
+ kind = "gitlab";
634
+ host;
635
+ api;
636
+ token;
637
+ fetchImpl;
638
+ constructor(host, token, fetchImpl = fetch) {
639
+ this.host = host;
640
+ this.token = token;
641
+ this.api = `https://${host}/api/v4`;
642
+ this.fetchImpl = fetchImpl;
643
+ }
644
+ async send(url) {
645
+ let response;
221
646
  try {
222
- const response = await this.fetchImpl(this.url(path), {
647
+ response = await this.fetchImpl(url, {
223
648
  headers: { "PRIVATE-TOKEN": this.token }
224
649
  });
225
- if (!response.ok) return "";
226
- return await response.text();
227
- } catch {
228
- return "";
650
+ } catch (cause) {
651
+ throw unreachable(this.host, cause);
229
652
  }
653
+ assertUsable(response, this.host);
654
+ return response;
655
+ }
656
+ async getJson(path, params) {
657
+ const response = await this.send(buildUrl(this.api, path, params));
658
+ if (!response.ok) return null;
659
+ try {
660
+ return await response.json();
661
+ } catch (cause) {
662
+ throw unreachable(this.host, cause);
663
+ }
664
+ }
665
+ async getText(path) {
666
+ const response = await this.send(buildUrl(this.api, path));
667
+ if (!response.ok) return "";
668
+ return await response.text();
230
669
  }
231
670
  async getPaged(path, params = {}, cap = 5) {
232
671
  const rows = [];
233
672
  for (let page = 1; page <= cap; page += 1) {
234
673
  const chunk = await this.getJson(path, {
235
674
  ...params,
236
- per_page: PAGE_SIZE,
675
+ per_page: PAGE_SIZE2,
237
676
  page
238
677
  });
239
678
  if (!chunk || chunk.length === 0) break;
240
679
  rows.push(...chunk);
241
- if (chunk.length < PAGE_SIZE) break;
680
+ if (chunk.length < PAGE_SIZE2) break;
242
681
  }
243
682
  return rows;
244
683
  }
@@ -284,7 +723,7 @@ var GitLabProvider = class {
284
723
  async countUnresolved(projectId, iid) {
285
724
  const discussions = await this.getJson(
286
725
  `projects/${projectId}/merge_requests/${iid}/discussions`,
287
- { per_page: PAGE_SIZE }
726
+ { per_page: PAGE_SIZE2 }
288
727
  );
289
728
  if (!discussions) return 0;
290
729
  return discussions.filter((discussion) => {
@@ -304,6 +743,7 @@ var GitLabProvider = class {
304
743
  ]);
305
744
  const latest = pipelines?.[0];
306
745
  return {
746
+ provider: "gitlab",
307
747
  project: String(mr.references.full).split("!")[0],
308
748
  projectId,
309
749
  iid,
@@ -338,15 +778,16 @@ var GitLabProvider = class {
338
778
  if (!approvals) return false;
339
779
  return (approvals.approved_by ?? []).some((entry2) => entry2.user?.id === uid);
340
780
  }
341
- async getReviews(uid, today) {
781
+ async getReviews(identity, today) {
342
782
  const raw = await this.getPaged("merge_requests", {
343
783
  scope: "all",
344
784
  state: "opened",
345
- reviewer_id: uid
785
+ reviewer_id: identity.id
346
786
  });
347
- const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS * MS_PER_DAY));
787
+ const cutoff = isoDay(new Date(today.getTime() - FRESH_REVIEW_DAYS2 * MS_PER_DAY));
348
788
  const rows = await Promise.all(
349
789
  raw.map(async (mr) => ({
790
+ provider: "gitlab",
350
791
  project: String(mr.references.full).split("!")[0],
351
792
  iid: mr.iid,
352
793
  title: mr.title,
@@ -355,7 +796,7 @@ var GitLabProvider = class {
355
796
  draft: mr.draft,
356
797
  url: mr.web_url,
357
798
  fresh: String(mr.updated_at).slice(0, 10) >= cutoff,
358
- approvedByMe: await this.approvedByMe(mr.project_id, mr.iid, uid)
799
+ approvedByMe: await this.approvedByMe(mr.project_id, mr.iid, identity.id)
359
800
  }))
360
801
  );
361
802
  return rows.sort((a, b) => b.updated.localeCompare(a.updated));
@@ -366,7 +807,7 @@ var GitLabProvider = class {
366
807
  red.map(async (mr) => {
367
808
  const jobs = await this.getJson(
368
809
  `projects/${mr.projectId}/pipelines/${mr.pipelineId}/jobs`,
369
- { per_page: PAGE_SIZE }
810
+ { per_page: PAGE_SIZE2 }
370
811
  ) ?? [];
371
812
  const job = jobs.find((j) => j.status === "failed");
372
813
  if (!job) return null;
@@ -374,6 +815,7 @@ var GitLabProvider = class {
374
815
  `projects/${mr.projectId}/jobs/${job.id}/trace`
375
816
  );
376
817
  return {
818
+ provider: "gitlab",
377
819
  project: mr.project,
378
820
  mr: mr.iid,
379
821
  title: mr.title,
@@ -388,6 +830,86 @@ var GitLabProvider = class {
388
830
  }
389
831
  };
390
832
 
833
+ // src/providers/select.ts
834
+ var DEFAULT_PROBE = { gitlab: glabHosts, github: ghHosts };
835
+ function hostFrom(cliHost, envHost, probe, fallback, labels) {
836
+ if (cliHost) return cliHost;
837
+ if (envHost) return envHost;
838
+ const found = probe();
839
+ return resolveHost(void 0, void 0, found.length > 0 ? found : fallback, labels);
840
+ }
841
+ function chooseKind(options = {}) {
842
+ const env = options.env ?? process.env;
843
+ const probe = options.probe ?? DEFAULT_PROBE;
844
+ if (options.provider) {
845
+ if (options.provider === "github" || options.provider === "gitlab") {
846
+ return options.provider;
847
+ }
848
+ throw new ConfigError(
849
+ `Unknown provider "${options.provider}" for --provider. Use github or gitlab.`
850
+ );
851
+ }
852
+ if (options.host) {
853
+ const host = options.host.toLowerCase();
854
+ if (host === DOT_COM || host.includes("github")) return "github";
855
+ if (host === "gitlab.com" || host.includes("gitlab")) return "gitlab";
856
+ }
857
+ if (env.STANDUP_PROVIDER) {
858
+ if (env.STANDUP_PROVIDER === "github" || env.STANDUP_PROVIDER === "gitlab") {
859
+ return env.STANDUP_PROVIDER;
860
+ }
861
+ throw new ConfigError(
862
+ `Unknown provider "${env.STANDUP_PROVIDER}" for STANDUP_PROVIDER. Use github or gitlab.`
863
+ );
864
+ }
865
+ const hasGitHubEnv = Boolean(env.GITHUB_HOST || env.GITHUB_TOKEN);
866
+ const hasGitLabEnv = Boolean(env.GITLAB_HOST || env.GITLAB_TOKEN);
867
+ if (hasGitHubEnv !== hasGitLabEnv) return hasGitHubEnv ? "github" : "gitlab";
868
+ if (hasGitHubEnv && hasGitLabEnv) {
869
+ const ghHost = Boolean(env.GITHUB_HOST);
870
+ const glabHost = Boolean(env.GITLAB_HOST);
871
+ if (ghHost !== glabHost) return ghHost ? "github" : "gitlab";
872
+ throw new ConfigError(
873
+ "Both GITHUB_* and GITLAB_* are configured. Pass --provider github or --provider gitlab."
874
+ );
875
+ }
876
+ const ghLoggedIn = probe.github().length > 0;
877
+ const glabLoggedIn = probe.gitlab().length > 0;
878
+ if (ghLoggedIn !== glabLoggedIn) return ghLoggedIn ? "github" : "gitlab";
879
+ if (ghLoggedIn && glabLoggedIn) {
880
+ throw new ConfigError(
881
+ "Both gh and glab are authenticated. Pass --provider github or --provider gitlab."
882
+ );
883
+ }
884
+ throw new ConfigError(
885
+ "No provider configured. Pass --provider with --host and --token, set GITHUB_* or GITLAB_* environment variables, or log in with gh or glab."
886
+ );
887
+ }
888
+ function connect(options = {}) {
889
+ const env = options.env ?? process.env;
890
+ const probe = options.probe ?? DEFAULT_PROBE;
891
+ if (chooseKind(options) === "github") {
892
+ const host2 = hostFrom(options.host, env.GITHUB_HOST, probe.github, [DOT_COM], GITHUB_LABELS);
893
+ const token2 = resolveToken(
894
+ host2,
895
+ options.token,
896
+ env.GITHUB_TOKEN,
897
+ ghToken,
898
+ GITHUB_LABELS
899
+ );
900
+ return new GitHubProvider(host2, token2);
901
+ }
902
+ const host = hostFrom(options.host, env.GITLAB_HOST, probe.gitlab, [], GITLAB_LABELS);
903
+ const token = resolveToken(
904
+ host,
905
+ options.token,
906
+ env.GITLAB_TOKEN,
907
+ glabToken,
908
+ GITLAB_LABELS
909
+ );
910
+ return new GitLabProvider(host, token);
911
+ }
912
+
391
913
  // src/report/report.constants.ts
392
914
  var LOOKBACK_DAYS = 21;
393
915
 
@@ -396,27 +918,27 @@ async function buildReport(provider, today, lang = "en", lookbackDays = LOOKBACK
396
918
  const identity = await provider.getIdentity();
397
919
  const since = new Date(today.getTime() - lookbackDays * MS_PER_DAY);
398
920
  const events = await provider.getEvents(since);
399
- const { date: previousDate, gapDays } = previousActiveDay(
921
+ const days = previousActiveDays(
400
922
  new Set(events.map((e) => e.at.slice(0, 10))),
401
923
  today
402
924
  );
403
- const previousEvents = previousDate ? events.filter((e) => e.at.slice(0, 10) === previousDate) : [];
925
+ const previousDays = days.map(({ date, gapDays }) => ({
926
+ date,
927
+ label: label(/* @__PURE__ */ new Date(`${date}T00:00:00`), lang),
928
+ gapDays,
929
+ events: events.filter((e) => e.at.slice(0, 10) === date)
930
+ }));
404
931
  const todayEvents = events.filter((e) => e.at.slice(0, 10) === isoDay(today));
405
932
  const myMrs = await provider.getMyMrs(today);
406
933
  const [reviews, blockers] = await Promise.all([
407
- provider.getReviews(identity.id, today),
934
+ provider.getReviews(identity, today),
408
935
  provider.getBlockers(myMrs)
409
936
  ]);
410
937
  return {
938
+ provider: provider.kind,
411
939
  user: identity.username,
412
940
  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,
941
+ previousDays,
420
942
  todayEvents,
421
943
  myMrs,
422
944
  reviews,
@@ -426,22 +948,17 @@ async function buildReport(provider, today, lang = "en", lookbackDays = LOOKBACK
426
948
  }
427
949
 
428
950
  // 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
951
  async function collect(options = {}) {
436
- return buildReport(resolveProvider(options), /* @__PURE__ */ new Date(), options.lang ?? "en");
952
+ const provider = options.providerImpl ?? connect({ provider: options.provider, host: options.host, token: options.token });
953
+ return buildReport(provider, /* @__PURE__ */ new Date(), options.lang ?? "en");
437
954
  }
438
955
  async function main() {
439
956
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
440
957
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
441
- const server = new McpServer({ name: "standup-mr", version: "0.1.0" });
958
+ const server = new McpServer({ name: "standup-mr", version: "0.2.0" });
442
959
  server.tool(
443
960
  "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.",
961
+ "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
962
  {},
446
963
  async () => {
447
964
  const report = await collect();