standup-mr 0.3.0 → 0.3.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.
- package/CHANGELOG.md +25 -0
- package/README.md +4 -0
- package/dist/{chunk-DL2P3XR6.js → chunk-2X2XEEUY.js} +103 -43
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.js +13 -1
- package/dist/mcp/server.js +97 -43
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ All notable changes to standup-mr are recorded here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
5
|
[semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.1] - 2026-09-01
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- One flaky sub-request no longer costs the whole note. A run fans out to
|
|
12
|
+
several requests per merge request — pull detail, check runs, reviews, and
|
|
13
|
+
for a red pipeline the run, the job and its log — and any one of them
|
|
14
|
+
returning a 502 aborted the entire standup. Server errors (5xx) and dropped
|
|
15
|
+
connections are now retried twice with a short backoff before giving up. When
|
|
16
|
+
the retries run out the error is raised exactly as before: nothing is
|
|
17
|
+
swallowed. A rejected token (401), a refused resource (403), a rate limit
|
|
18
|
+
(429) and a missing resource (404) are not retried, because retrying them is
|
|
19
|
+
never the right answer.
|
|
20
|
+
- A blocker whose diagnosis could not be fetched is now reported as a blocker
|
|
21
|
+
with `job: "unknown"` and an error line reading
|
|
22
|
+
`diagnosis unavailable: <reason>`, instead of failing the run. The merge
|
|
23
|
+
request is genuinely blocked either way, and the log fetch already degraded
|
|
24
|
+
this way; the metadata calls around it now match. **A 401 is never degraded**
|
|
25
|
+
— a revoked or invalid token still fails loudly, which is the whole reason
|
|
26
|
+
these calls throw in the first place.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `mcpName` in `package.json`, for publication to the official MCP Registry.
|
|
31
|
+
|
|
7
32
|
## [0.3.0] - 2026-09-01
|
|
8
33
|
|
|
9
34
|
### Added
|
package/README.md
CHANGED
|
@@ -188,6 +188,10 @@ that the package is missing.
|
|
|
188
188
|
- **On GitHub, CI that reports only through the legacy commit-statuses API**
|
|
189
189
|
— still how some vendors integrate — shows up as `pipelineMissing`. Check
|
|
190
190
|
state is read from check-runs only.
|
|
191
|
+
- **A blocker whose diagnosis could not be fetched is still reported**, as
|
|
192
|
+
`job: "unknown"` with a `diagnosis unavailable: …` error line. The merge
|
|
193
|
+
request is blocked either way; only the explanation is missing. Server
|
|
194
|
+
errors are retried twice first, and a rejected token still fails the run.
|
|
191
195
|
|
|
192
196
|
## Upgrading from 0.1.x
|
|
193
197
|
|
|
@@ -218,9 +218,11 @@ function buildUrl(api, path, params) {
|
|
|
218
218
|
return `${base}?${query.toString()}`;
|
|
219
219
|
}
|
|
220
220
|
var ApiError = class extends Error {
|
|
221
|
-
|
|
221
|
+
status;
|
|
222
|
+
constructor(message, status) {
|
|
222
223
|
super(message);
|
|
223
224
|
this.name = "ApiError";
|
|
225
|
+
this.status = status;
|
|
224
226
|
}
|
|
225
227
|
};
|
|
226
228
|
function remaining(response) {
|
|
@@ -244,27 +246,67 @@ function assertUsable(response, host) {
|
|
|
244
246
|
if (response.ok || response.status === 404) return;
|
|
245
247
|
if (response.status === 403 || response.status === 429) {
|
|
246
248
|
if (remaining(response) === "0") {
|
|
247
|
-
throw new ApiError(`${host} rate limit reached${resetAt(response)}
|
|
249
|
+
throw new ApiError(`${host} rate limit reached${resetAt(response)}.`, response.status);
|
|
248
250
|
}
|
|
249
251
|
const retry = retryAfter(response);
|
|
250
|
-
if (retry) throw new ApiError(`${host} rate limit reached${retry}
|
|
252
|
+
if (retry) throw new ApiError(`${host} rate limit reached${retry}.`, response.status);
|
|
251
253
|
}
|
|
252
254
|
if (response.status === 401) {
|
|
253
255
|
throw new ApiError(
|
|
254
|
-
`${host} rejected the token (401). Check the token and its scopes
|
|
256
|
+
`${host} rejected the token (401). Check the token and its scopes.`,
|
|
257
|
+
401
|
|
255
258
|
);
|
|
256
259
|
}
|
|
257
260
|
if (response.status === 403) {
|
|
258
261
|
throw new ApiError(
|
|
259
|
-
`${host} refused the request (403). The token has no access to that resource
|
|
262
|
+
`${host} refused the request (403). The token has no access to that resource.`,
|
|
263
|
+
403
|
|
260
264
|
);
|
|
261
265
|
}
|
|
262
|
-
throw new ApiError(`${host} returned ${response.status}
|
|
266
|
+
throw new ApiError(`${host} returned ${response.status}.`, response.status);
|
|
263
267
|
}
|
|
264
268
|
function unreachable(host, cause) {
|
|
265
269
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
266
270
|
return new ApiError(`Could not reach ${host}: ${detail}`);
|
|
267
271
|
}
|
|
272
|
+
var RETRY_ATTEMPTS = 3;
|
|
273
|
+
var RETRY_BACKOFF_MS = [250, 750];
|
|
274
|
+
var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
275
|
+
async function sendWithRetry(fetchImpl, url, init, host, sleep = wait) {
|
|
276
|
+
let last;
|
|
277
|
+
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
|
|
278
|
+
if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
279
|
+
const final = attempt === RETRY_ATTEMPTS - 1;
|
|
280
|
+
try {
|
|
281
|
+
const response = await fetchImpl(url, init);
|
|
282
|
+
if (response.status >= 500 && !final) continue;
|
|
283
|
+
return response;
|
|
284
|
+
} catch (cause) {
|
|
285
|
+
last = cause;
|
|
286
|
+
if (final) throw unreachable(host, cause);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
throw unreachable(host, last);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/providers/base/diagnosis.ts
|
|
293
|
+
var DIAGNOSIS_UNAVAILABLE = "diagnosis unavailable";
|
|
294
|
+
var UNKNOWN = "unknown";
|
|
295
|
+
function degradable(cause) {
|
|
296
|
+
return cause instanceof ApiError && cause.status !== 401;
|
|
297
|
+
}
|
|
298
|
+
function undiagnosed(mr, cause) {
|
|
299
|
+
return {
|
|
300
|
+
provider: mr.provider,
|
|
301
|
+
project: mr.project,
|
|
302
|
+
mr: mr.iid,
|
|
303
|
+
title: mr.title,
|
|
304
|
+
job: UNKNOWN,
|
|
305
|
+
stage: UNKNOWN,
|
|
306
|
+
url: mr.url,
|
|
307
|
+
errors: [`${DIAGNOSIS_UNAVAILABLE}: ${cause.message}`]
|
|
308
|
+
};
|
|
309
|
+
}
|
|
268
310
|
|
|
269
311
|
// src/trace/trace.constants.ts
|
|
270
312
|
var ANSI = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
|
@@ -414,11 +456,12 @@ var GitHubProvider = class {
|
|
|
414
456
|
};
|
|
415
457
|
}
|
|
416
458
|
async send(url, init) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
459
|
+
return await sendWithRetry(
|
|
460
|
+
this.fetchImpl,
|
|
461
|
+
url,
|
|
462
|
+
{ headers: this.headers(), ...init },
|
|
463
|
+
this.host
|
|
464
|
+
);
|
|
422
465
|
}
|
|
423
466
|
async getJson(path, params) {
|
|
424
467
|
const response = await this.send(buildUrl(this.api, path, params));
|
|
@@ -648,9 +691,17 @@ var GitHubProvider = class {
|
|
|
648
691
|
}
|
|
649
692
|
async getBlockers(mrs) {
|
|
650
693
|
const red = mrs.filter((mr) => mr.pipeline === "failed");
|
|
651
|
-
const diagnosed = await Promise.all(red.map((mr) => this.
|
|
694
|
+
const diagnosed = await Promise.all(red.map((mr) => this.tryDiagnose(mr)));
|
|
652
695
|
return diagnosed.filter((row) => row !== null);
|
|
653
696
|
}
|
|
697
|
+
async tryDiagnose(mr) {
|
|
698
|
+
try {
|
|
699
|
+
return await this.diagnose(mr);
|
|
700
|
+
} catch (cause) {
|
|
701
|
+
if (degradable(cause)) return undiagnosed(mr, cause);
|
|
702
|
+
throw cause;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
654
705
|
};
|
|
655
706
|
|
|
656
707
|
// src/providers/gitlab/gitlab.constants.ts
|
|
@@ -671,14 +722,12 @@ var GitLabProvider = class {
|
|
|
671
722
|
this.fetchImpl = fetchImpl;
|
|
672
723
|
}
|
|
673
724
|
async send(url) {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
throw unreachable(this.host, cause);
|
|
681
|
-
}
|
|
725
|
+
const response = await sendWithRetry(
|
|
726
|
+
this.fetchImpl,
|
|
727
|
+
url,
|
|
728
|
+
{ headers: { "PRIVATE-TOKEN": this.token } },
|
|
729
|
+
this.host
|
|
730
|
+
);
|
|
682
731
|
assertUsable(response, this.host);
|
|
683
732
|
return response;
|
|
684
733
|
}
|
|
@@ -830,31 +879,36 @@ var GitLabProvider = class {
|
|
|
830
879
|
);
|
|
831
880
|
return rows.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
832
881
|
}
|
|
882
|
+
async diagnose(mr) {
|
|
883
|
+
const jobs = await this.getJson(
|
|
884
|
+
`projects/${mr.projectId}/pipelines/${mr.pipelineId}/jobs`,
|
|
885
|
+
{ per_page: PAGE_SIZE2 }
|
|
886
|
+
) ?? [];
|
|
887
|
+
const job = jobs.find((j) => j.status === "failed");
|
|
888
|
+
if (!job) return null;
|
|
889
|
+
const trace = await this.getText(`projects/${mr.projectId}/jobs/${job.id}/trace`);
|
|
890
|
+
return {
|
|
891
|
+
provider: "gitlab",
|
|
892
|
+
project: mr.project,
|
|
893
|
+
mr: mr.iid,
|
|
894
|
+
title: mr.title,
|
|
895
|
+
job: job.name,
|
|
896
|
+
stage: job.stage,
|
|
897
|
+
url: mr.url,
|
|
898
|
+
errors: extractErrors(trace)
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
async tryDiagnose(mr) {
|
|
902
|
+
try {
|
|
903
|
+
return await this.diagnose(mr);
|
|
904
|
+
} catch (cause) {
|
|
905
|
+
if (degradable(cause)) return undiagnosed(mr, cause);
|
|
906
|
+
throw cause;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
833
909
|
async getBlockers(mrs) {
|
|
834
910
|
const red = mrs.filter((mr) => mr.pipeline === "failed");
|
|
835
|
-
const diagnosed = await Promise.all(
|
|
836
|
-
red.map(async (mr) => {
|
|
837
|
-
const jobs = await this.getJson(
|
|
838
|
-
`projects/${mr.projectId}/pipelines/${mr.pipelineId}/jobs`,
|
|
839
|
-
{ per_page: PAGE_SIZE2 }
|
|
840
|
-
) ?? [];
|
|
841
|
-
const job = jobs.find((j) => j.status === "failed");
|
|
842
|
-
if (!job) return null;
|
|
843
|
-
const trace = await this.getText(
|
|
844
|
-
`projects/${mr.projectId}/jobs/${job.id}/trace`
|
|
845
|
-
);
|
|
846
|
-
return {
|
|
847
|
-
provider: "gitlab",
|
|
848
|
-
project: mr.project,
|
|
849
|
-
mr: mr.iid,
|
|
850
|
-
title: mr.title,
|
|
851
|
-
job: job.name,
|
|
852
|
-
stage: job.stage,
|
|
853
|
-
url: mr.url,
|
|
854
|
-
errors: extractErrors(trace)
|
|
855
|
-
};
|
|
856
|
-
})
|
|
857
|
-
);
|
|
911
|
+
const diagnosed = await Promise.all(red.map((mr) => this.tryDiagnose(mr)));
|
|
858
912
|
return diagnosed.filter((row) => row !== null);
|
|
859
913
|
}
|
|
860
914
|
};
|
|
@@ -1103,6 +1157,12 @@ export {
|
|
|
1103
1157
|
ApiError,
|
|
1104
1158
|
assertUsable,
|
|
1105
1159
|
unreachable,
|
|
1160
|
+
RETRY_ATTEMPTS,
|
|
1161
|
+
RETRY_BACKOFF_MS,
|
|
1162
|
+
sendWithRetry,
|
|
1163
|
+
DIAGNOSIS_UNAVAILABLE,
|
|
1164
|
+
degradable,
|
|
1165
|
+
undiagnosed,
|
|
1106
1166
|
extractErrors,
|
|
1107
1167
|
repoFromUrl,
|
|
1108
1168
|
mapEvent,
|
package/dist/cli.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -142,10 +142,19 @@ interface Provider {
|
|
|
142
142
|
|
|
143
143
|
declare function buildUrl(api: string, path: string, params?: Record<string, string | number>): string;
|
|
144
144
|
declare class ApiError extends Error {
|
|
145
|
-
|
|
145
|
+
readonly status?: number;
|
|
146
|
+
constructor(message: string, status?: number);
|
|
146
147
|
}
|
|
147
148
|
declare function assertUsable(response: Response, host: string): void;
|
|
148
149
|
declare function unreachable(host: string, cause: unknown): ApiError;
|
|
150
|
+
declare const RETRY_ATTEMPTS = 3;
|
|
151
|
+
declare const RETRY_BACKOFF_MS: number[];
|
|
152
|
+
type Sleep = (ms: number) => Promise<void>;
|
|
153
|
+
declare function sendWithRetry(fetchImpl: FetchLike, url: string, init: RequestInit | undefined, host: string, sleep?: Sleep): Promise<Response>;
|
|
154
|
+
|
|
155
|
+
declare const DIAGNOSIS_UNAVAILABLE = "diagnosis unavailable";
|
|
156
|
+
declare function degradable(cause: unknown): cause is ApiError;
|
|
157
|
+
declare function undiagnosed(mr: MergeRequest, cause: ApiError): Blocker;
|
|
149
158
|
|
|
150
159
|
type Params$1 = Record<string, string | number>;
|
|
151
160
|
declare class GitHubProvider implements Provider {
|
|
@@ -171,6 +180,7 @@ declare class GitHubProvider implements Provider {
|
|
|
171
180
|
private checkRunBlocker;
|
|
172
181
|
private diagnose;
|
|
173
182
|
getBlockers(mrs: MergeRequest[]): Promise<Blocker[]>;
|
|
183
|
+
private tryDiagnose;
|
|
174
184
|
}
|
|
175
185
|
|
|
176
186
|
interface RawCommit {
|
|
@@ -247,6 +257,8 @@ declare class GitLabProvider implements Provider {
|
|
|
247
257
|
getMyMrs(today: Date): Promise<MergeRequest[]>;
|
|
248
258
|
private approvedByMe;
|
|
249
259
|
getReviews(identity: Identity, today: Date): Promise<Review[]>;
|
|
260
|
+
private diagnose;
|
|
261
|
+
private tryDiagnose;
|
|
250
262
|
getBlockers(mrs: MergeRequest[]): Promise<Blocker[]>;
|
|
251
263
|
}
|
|
252
264
|
|
|
@@ -269,4 +281,4 @@ declare function buildReport(provider: Provider, today: Date, lang?: string, loo
|
|
|
269
281
|
|
|
270
282
|
declare function extractErrors(rawTrace: string, limit?: number): string[];
|
|
271
283
|
|
|
272
|
-
export { type ActiveDay, type ActivityEvent, ApiError, type Blocker, type Bucket, ConfigError, type FetchLike, GITHUB_LABELS, GITLAB_LABELS, GitHubProvider, GitLabProvider, type Identity, type MergeRequest, type Provider, type ProviderKind, type ProviderLabels, type Review, STALE_DAYS, type SelectOptions, type StandupReport, approvedBy, assertUsable, buildReport, buildUrl, chooseKind, classify, connect, countChangesRequested, extractErrors, ghHosts, ghToken, glabHosts, glabToken, isoDay, label, latestStateByReviewer, localAt, mapEvent, markMissingPipelines, normalizeChecks, parseGlabHosts, parseLoggedInHosts, postWebhook, previousActiveDays, repoFromUrl, resolveHost, resolveToken, toMarkdown, unreachable };
|
|
284
|
+
export { type ActiveDay, type ActivityEvent, ApiError, type Blocker, type Bucket, ConfigError, DIAGNOSIS_UNAVAILABLE, type FetchLike, GITHUB_LABELS, GITLAB_LABELS, GitHubProvider, GitLabProvider, type Identity, type MergeRequest, type Provider, type ProviderKind, type ProviderLabels, RETRY_ATTEMPTS, RETRY_BACKOFF_MS, type Review, STALE_DAYS, type SelectOptions, type StandupReport, approvedBy, assertUsable, buildReport, buildUrl, chooseKind, classify, connect, countChangesRequested, degradable, extractErrors, ghHosts, ghToken, glabHosts, glabToken, isoDay, label, latestStateByReviewer, localAt, mapEvent, markMissingPipelines, normalizeChecks, parseGlabHosts, parseLoggedInHosts, postWebhook, previousActiveDays, repoFromUrl, resolveHost, resolveToken, sendWithRetry, toMarkdown, undiagnosed, unreachable };
|
package/dist/index.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
import {
|
|
3
3
|
ApiError,
|
|
4
4
|
ConfigError,
|
|
5
|
+
DIAGNOSIS_UNAVAILABLE,
|
|
5
6
|
GITHUB_LABELS,
|
|
6
7
|
GITLAB_LABELS,
|
|
7
8
|
GitHubProvider,
|
|
8
9
|
GitLabProvider,
|
|
10
|
+
RETRY_ATTEMPTS,
|
|
11
|
+
RETRY_BACKOFF_MS,
|
|
9
12
|
STALE_DAYS,
|
|
10
13
|
approvedBy,
|
|
11
14
|
assertUsable,
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
classify,
|
|
16
19
|
connect,
|
|
17
20
|
countChangesRequested,
|
|
21
|
+
degradable,
|
|
18
22
|
extractErrors,
|
|
19
23
|
ghHosts,
|
|
20
24
|
ghToken,
|
|
@@ -34,16 +38,21 @@ import {
|
|
|
34
38
|
repoFromUrl,
|
|
35
39
|
resolveHost,
|
|
36
40
|
resolveToken,
|
|
41
|
+
sendWithRetry,
|
|
37
42
|
toMarkdown,
|
|
43
|
+
undiagnosed,
|
|
38
44
|
unreachable
|
|
39
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-2X2XEEUY.js";
|
|
40
46
|
export {
|
|
41
47
|
ApiError,
|
|
42
48
|
ConfigError,
|
|
49
|
+
DIAGNOSIS_UNAVAILABLE,
|
|
43
50
|
GITHUB_LABELS,
|
|
44
51
|
GITLAB_LABELS,
|
|
45
52
|
GitHubProvider,
|
|
46
53
|
GitLabProvider,
|
|
54
|
+
RETRY_ATTEMPTS,
|
|
55
|
+
RETRY_BACKOFF_MS,
|
|
47
56
|
STALE_DAYS,
|
|
48
57
|
approvedBy,
|
|
49
58
|
assertUsable,
|
|
@@ -53,6 +62,7 @@ export {
|
|
|
53
62
|
classify,
|
|
54
63
|
connect,
|
|
55
64
|
countChangesRequested,
|
|
65
|
+
degradable,
|
|
56
66
|
extractErrors,
|
|
57
67
|
ghHosts,
|
|
58
68
|
ghToken,
|
|
@@ -72,6 +82,8 @@ export {
|
|
|
72
82
|
repoFromUrl,
|
|
73
83
|
resolveHost,
|
|
74
84
|
resolveToken,
|
|
85
|
+
sendWithRetry,
|
|
75
86
|
toMarkdown,
|
|
87
|
+
undiagnosed,
|
|
76
88
|
unreachable
|
|
77
89
|
};
|
package/dist/mcp/server.js
CHANGED
|
@@ -243,9 +243,11 @@ function buildUrl(api, path, params) {
|
|
|
243
243
|
return `${base}?${query.toString()}`;
|
|
244
244
|
}
|
|
245
245
|
var ApiError = class extends Error {
|
|
246
|
-
|
|
246
|
+
status;
|
|
247
|
+
constructor(message, status) {
|
|
247
248
|
super(message);
|
|
248
249
|
this.name = "ApiError";
|
|
250
|
+
this.status = status;
|
|
249
251
|
}
|
|
250
252
|
};
|
|
251
253
|
function remaining(response) {
|
|
@@ -269,27 +271,67 @@ function assertUsable(response, host) {
|
|
|
269
271
|
if (response.ok || response.status === 404) return;
|
|
270
272
|
if (response.status === 403 || response.status === 429) {
|
|
271
273
|
if (remaining(response) === "0") {
|
|
272
|
-
throw new ApiError(`${host} rate limit reached${resetAt(response)}
|
|
274
|
+
throw new ApiError(`${host} rate limit reached${resetAt(response)}.`, response.status);
|
|
273
275
|
}
|
|
274
276
|
const retry = retryAfter(response);
|
|
275
|
-
if (retry) throw new ApiError(`${host} rate limit reached${retry}
|
|
277
|
+
if (retry) throw new ApiError(`${host} rate limit reached${retry}.`, response.status);
|
|
276
278
|
}
|
|
277
279
|
if (response.status === 401) {
|
|
278
280
|
throw new ApiError(
|
|
279
|
-
`${host} rejected the token (401). Check the token and its scopes
|
|
281
|
+
`${host} rejected the token (401). Check the token and its scopes.`,
|
|
282
|
+
401
|
|
280
283
|
);
|
|
281
284
|
}
|
|
282
285
|
if (response.status === 403) {
|
|
283
286
|
throw new ApiError(
|
|
284
|
-
`${host} refused the request (403). The token has no access to that resource
|
|
287
|
+
`${host} refused the request (403). The token has no access to that resource.`,
|
|
288
|
+
403
|
|
285
289
|
);
|
|
286
290
|
}
|
|
287
|
-
throw new ApiError(`${host} returned ${response.status}
|
|
291
|
+
throw new ApiError(`${host} returned ${response.status}.`, response.status);
|
|
288
292
|
}
|
|
289
293
|
function unreachable(host, cause) {
|
|
290
294
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
291
295
|
return new ApiError(`Could not reach ${host}: ${detail}`);
|
|
292
296
|
}
|
|
297
|
+
var RETRY_ATTEMPTS = 3;
|
|
298
|
+
var RETRY_BACKOFF_MS = [250, 750];
|
|
299
|
+
var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
300
|
+
async function sendWithRetry(fetchImpl, url, init, host, sleep = wait) {
|
|
301
|
+
let last;
|
|
302
|
+
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
|
|
303
|
+
if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
304
|
+
const final = attempt === RETRY_ATTEMPTS - 1;
|
|
305
|
+
try {
|
|
306
|
+
const response = await fetchImpl(url, init);
|
|
307
|
+
if (response.status >= 500 && !final) continue;
|
|
308
|
+
return response;
|
|
309
|
+
} catch (cause) {
|
|
310
|
+
last = cause;
|
|
311
|
+
if (final) throw unreachable(host, cause);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
throw unreachable(host, last);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/providers/base/diagnosis.ts
|
|
318
|
+
var DIAGNOSIS_UNAVAILABLE = "diagnosis unavailable";
|
|
319
|
+
var UNKNOWN = "unknown";
|
|
320
|
+
function degradable(cause) {
|
|
321
|
+
return cause instanceof ApiError && cause.status !== 401;
|
|
322
|
+
}
|
|
323
|
+
function undiagnosed(mr, cause) {
|
|
324
|
+
return {
|
|
325
|
+
provider: mr.provider,
|
|
326
|
+
project: mr.project,
|
|
327
|
+
mr: mr.iid,
|
|
328
|
+
title: mr.title,
|
|
329
|
+
job: UNKNOWN,
|
|
330
|
+
stage: UNKNOWN,
|
|
331
|
+
url: mr.url,
|
|
332
|
+
errors: [`${DIAGNOSIS_UNAVAILABLE}: ${cause.message}`]
|
|
333
|
+
};
|
|
334
|
+
}
|
|
293
335
|
|
|
294
336
|
// src/providers/github/github.constants.ts
|
|
295
337
|
var PAGE_SIZE = 100;
|
|
@@ -398,11 +440,12 @@ var GitHubProvider = class {
|
|
|
398
440
|
};
|
|
399
441
|
}
|
|
400
442
|
async send(url, init) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
443
|
+
return await sendWithRetry(
|
|
444
|
+
this.fetchImpl,
|
|
445
|
+
url,
|
|
446
|
+
{ headers: this.headers(), ...init },
|
|
447
|
+
this.host
|
|
448
|
+
);
|
|
406
449
|
}
|
|
407
450
|
async getJson(path, params) {
|
|
408
451
|
const response = await this.send(buildUrl(this.api, path, params));
|
|
@@ -632,9 +675,17 @@ var GitHubProvider = class {
|
|
|
632
675
|
}
|
|
633
676
|
async getBlockers(mrs) {
|
|
634
677
|
const red = mrs.filter((mr) => mr.pipeline === "failed");
|
|
635
|
-
const diagnosed = await Promise.all(red.map((mr) => this.
|
|
678
|
+
const diagnosed = await Promise.all(red.map((mr) => this.tryDiagnose(mr)));
|
|
636
679
|
return diagnosed.filter((row) => row !== null);
|
|
637
680
|
}
|
|
681
|
+
async tryDiagnose(mr) {
|
|
682
|
+
try {
|
|
683
|
+
return await this.diagnose(mr);
|
|
684
|
+
} catch (cause) {
|
|
685
|
+
if (degradable(cause)) return undiagnosed(mr, cause);
|
|
686
|
+
throw cause;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
638
689
|
};
|
|
639
690
|
|
|
640
691
|
// src/providers/gitlab/gitlab.constants.ts
|
|
@@ -655,14 +706,12 @@ var GitLabProvider = class {
|
|
|
655
706
|
this.fetchImpl = fetchImpl;
|
|
656
707
|
}
|
|
657
708
|
async send(url) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
throw unreachable(this.host, cause);
|
|
665
|
-
}
|
|
709
|
+
const response = await sendWithRetry(
|
|
710
|
+
this.fetchImpl,
|
|
711
|
+
url,
|
|
712
|
+
{ headers: { "PRIVATE-TOKEN": this.token } },
|
|
713
|
+
this.host
|
|
714
|
+
);
|
|
666
715
|
assertUsable(response, this.host);
|
|
667
716
|
return response;
|
|
668
717
|
}
|
|
@@ -814,31 +863,36 @@ var GitLabProvider = class {
|
|
|
814
863
|
);
|
|
815
864
|
return rows.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
816
865
|
}
|
|
866
|
+
async diagnose(mr) {
|
|
867
|
+
const jobs = await this.getJson(
|
|
868
|
+
`projects/${mr.projectId}/pipelines/${mr.pipelineId}/jobs`,
|
|
869
|
+
{ per_page: PAGE_SIZE2 }
|
|
870
|
+
) ?? [];
|
|
871
|
+
const job = jobs.find((j) => j.status === "failed");
|
|
872
|
+
if (!job) return null;
|
|
873
|
+
const trace = await this.getText(`projects/${mr.projectId}/jobs/${job.id}/trace`);
|
|
874
|
+
return {
|
|
875
|
+
provider: "gitlab",
|
|
876
|
+
project: mr.project,
|
|
877
|
+
mr: mr.iid,
|
|
878
|
+
title: mr.title,
|
|
879
|
+
job: job.name,
|
|
880
|
+
stage: job.stage,
|
|
881
|
+
url: mr.url,
|
|
882
|
+
errors: extractErrors(trace)
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
async tryDiagnose(mr) {
|
|
886
|
+
try {
|
|
887
|
+
return await this.diagnose(mr);
|
|
888
|
+
} catch (cause) {
|
|
889
|
+
if (degradable(cause)) return undiagnosed(mr, cause);
|
|
890
|
+
throw cause;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
817
893
|
async getBlockers(mrs) {
|
|
818
894
|
const red = mrs.filter((mr) => mr.pipeline === "failed");
|
|
819
|
-
const diagnosed = await Promise.all(
|
|
820
|
-
red.map(async (mr) => {
|
|
821
|
-
const jobs = await this.getJson(
|
|
822
|
-
`projects/${mr.projectId}/pipelines/${mr.pipelineId}/jobs`,
|
|
823
|
-
{ per_page: PAGE_SIZE2 }
|
|
824
|
-
) ?? [];
|
|
825
|
-
const job = jobs.find((j) => j.status === "failed");
|
|
826
|
-
if (!job) return null;
|
|
827
|
-
const trace = await this.getText(
|
|
828
|
-
`projects/${mr.projectId}/jobs/${job.id}/trace`
|
|
829
|
-
);
|
|
830
|
-
return {
|
|
831
|
-
provider: "gitlab",
|
|
832
|
-
project: mr.project,
|
|
833
|
-
mr: mr.iid,
|
|
834
|
-
title: mr.title,
|
|
835
|
-
job: job.name,
|
|
836
|
-
stage: job.stage,
|
|
837
|
-
url: mr.url,
|
|
838
|
-
errors: extractErrors(trace)
|
|
839
|
-
};
|
|
840
|
-
})
|
|
841
|
-
);
|
|
895
|
+
const diagnosed = await Promise.all(red.map((mr) => this.tryDiagnose(mr)));
|
|
842
896
|
return diagnosed.filter((row) => row !== null);
|
|
843
897
|
}
|
|
844
898
|
};
|