@multipliers-dev/renovate-workflow 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.
@@ -1,624 +0,0 @@
1
- export const FRESHNESS_POLL_INTERVAL_MS = 10_000;
2
- export const FRESHNESS_UNKNOWN_MAX = 3;
3
- export const CI_BABYSIT_BUDGET_MS = 5 * 60_000;
4
-
5
- export type MergeState =
6
- | "CLEAN"
7
- | "BEHIND"
8
- | "BLOCKED"
9
- | "DIRTY"
10
- | "UNKNOWN"
11
- | "CONFLICTING"
12
- | "UNSTABLE"
13
- | "HAS_HOOKS"
14
- | (string & {});
15
-
16
- export type CiState = "pending" | "success" | "failure" | "not_found" | "ambiguous";
17
-
18
- export type MergeObservation = {
19
- mergeState: MergeState;
20
- headSha: string;
21
- baseSha?: string;
22
- detail?: string;
23
- };
24
-
25
- export type CiObservation = {
26
- ciState: CiState;
27
- headSha?: string;
28
- detail?: string;
29
- };
30
-
31
- export type RenovateBabysitFailureOutcome =
32
- | "unknown_exhausted"
33
- | "budget_exhausted"
34
- | "merge_query_failed"
35
- | "ci_query_failed"
36
- | "ci_failed"
37
- | "non_clean"
38
- | "head_changed";
39
-
40
- export type RenovateBabysitResult =
41
- | {
42
- outcome: "clean";
43
- headSha: string;
44
- freshnessRechecks: number;
45
- ciPolls: number;
46
- elapsedMs: number;
47
- }
48
- | {
49
- outcome: RenovateBabysitFailureOutcome;
50
- observedHeadSha?: string;
51
- mergeState?: MergeState;
52
- ciState?: CiState;
53
- freshnessRechecks: number;
54
- ciPolls: number;
55
- elapsedMs: number;
56
- detail?: string;
57
- };
58
-
59
- export type RenovateBabysitConfig = {
60
- pollIntervalMs?: number;
61
- unknownMax?: number;
62
- ciBudgetMs?: number;
63
- };
64
-
65
- export type RenovateBabysitDeps = {
66
- now: () => number;
67
- sleep: (ms: number) => Promise<void>;
68
- fetchMergeState: () => Promise<MergeObservation>;
69
- fetchCiState: (expectedHeadSha: string) => Promise<CiObservation>;
70
- };
71
-
72
- export type RenovateBabysitOptions = {
73
- expectedHeadSha: string;
74
- config?: RenovateBabysitConfig;
75
- };
76
-
77
- type RuntimeConfig = {
78
- pollIntervalMs: number;
79
- unknownMax: number;
80
- ciBudgetMs: number;
81
- };
82
-
83
- type Counters = {
84
- freshnessRechecks: number;
85
- ciPolls: number;
86
- };
87
-
88
- type PollState = {
89
- consecutiveUnknown: number;
90
- };
91
-
92
- const SUCCESSFUL_CI_STATES = new Set(["success", "neutral"]);
93
- const PENDING_CI_STATUSES = new Set(["pending", "queued", "in_progress", "requested", "waiting"]);
94
- const FAILURE_CI_STATES = new Set([
95
- "action_required",
96
- "cancelled",
97
- "failure",
98
- "skipped",
99
- "startup_failure",
100
- "timed_out",
101
- "error",
102
- ]);
103
-
104
- function runtimeConfig(config: RenovateBabysitConfig | undefined): RuntimeConfig {
105
- return {
106
- pollIntervalMs: config?.pollIntervalMs ?? FRESHNESS_POLL_INTERVAL_MS,
107
- unknownMax: config?.unknownMax ?? FRESHNESS_UNKNOWN_MAX,
108
- ciBudgetMs: config?.ciBudgetMs ?? CI_BABYSIT_BUDGET_MS,
109
- };
110
- }
111
-
112
- function elapsedMs(deps: RenovateBabysitDeps, startMs: number): number {
113
- return deps.now() - startMs;
114
- }
115
-
116
- function detailFromError(error: unknown): string {
117
- return error instanceof Error ? error.message : String(error);
118
- }
119
-
120
- function isValidHeadSha(value: unknown): value is string {
121
- return typeof value === "string" && value.trim().length > 0;
122
- }
123
-
124
- function failure(
125
- outcome: RenovateBabysitFailureOutcome,
126
- deps: RenovateBabysitDeps,
127
- startMs: number,
128
- counters: Counters,
129
- context: {
130
- observedHeadSha?: string;
131
- mergeState?: MergeState;
132
- ciState?: CiState;
133
- detail?: string;
134
- } = {}
135
- ): RenovateBabysitResult {
136
- return {
137
- outcome,
138
- observedHeadSha: context.observedHeadSha,
139
- mergeState: context.mergeState,
140
- ciState: context.ciState,
141
- freshnessRechecks: counters.freshnessRechecks,
142
- ciPolls: counters.ciPolls,
143
- elapsedMs: elapsedMs(deps, startMs),
144
- detail: context.detail,
145
- };
146
- }
147
-
148
- function clean(
149
- observation: MergeObservation,
150
- deps: RenovateBabysitDeps,
151
- startMs: number,
152
- counters: Counters
153
- ): RenovateBabysitResult {
154
- return {
155
- outcome: "clean",
156
- headSha: observation.headSha,
157
- freshnessRechecks: counters.freshnessRechecks,
158
- ciPolls: counters.ciPolls,
159
- elapsedMs: elapsedMs(deps, startMs),
160
- };
161
- }
162
-
163
- async function fetchMergeObservation(
164
- deps: RenovateBabysitDeps,
165
- startMs: number,
166
- counters: Counters
167
- ): Promise<MergeObservation | RenovateBabysitResult> {
168
- try {
169
- const observation = await deps.fetchMergeState();
170
- if (!isValidHeadSha(observation.headSha)) {
171
- return failure("merge_query_failed", deps, startMs, counters, {
172
- mergeState: observation.mergeState,
173
- detail: "merge-state query returned a missing or blank head SHA",
174
- });
175
- }
176
- return observation;
177
- } catch (error) {
178
- return failure("merge_query_failed", deps, startMs, counters, {
179
- detail: detailFromError(error),
180
- });
181
- }
182
- }
183
-
184
- function validateExpectedHead(
185
- observation: MergeObservation,
186
- expectedHeadSha: string,
187
- deps: RenovateBabysitDeps,
188
- startMs: number,
189
- counters: Counters
190
- ): RenovateBabysitResult | null {
191
- if (observation.headSha !== expectedHeadSha) {
192
- return failure("head_changed", deps, startMs, counters, {
193
- observedHeadSha: observation.headSha,
194
- mergeState: observation.mergeState,
195
- detail: `expected ${expectedHeadSha}, observed ${observation.headSha}`,
196
- });
197
- }
198
- return null;
199
- }
200
-
201
- function canContinueWithinBudget(
202
- deps: RenovateBabysitDeps,
203
- ciBudgetStartedAtMs: number | undefined,
204
- config: RuntimeConfig
205
- ): boolean {
206
- if (ciBudgetStartedAtMs === undefined) {
207
- return true;
208
- }
209
- return deps.now() - ciBudgetStartedAtMs < config.ciBudgetMs;
210
- }
211
-
212
- function canSleepWithinBudget(
213
- deps: RenovateBabysitDeps,
214
- ciBudgetStartedAtMs: number | undefined,
215
- config: RuntimeConfig
216
- ): boolean {
217
- if (ciBudgetStartedAtMs === undefined) {
218
- return true;
219
- }
220
- return deps.now() - ciBudgetStartedAtMs + config.pollIntervalMs <= config.ciBudgetMs;
221
- }
222
-
223
- async function sleepBeforeNextObservation(
224
- deps: RenovateBabysitDeps,
225
- startMs: number,
226
- counters: Counters,
227
- ciBudgetStartedAtMs: number | undefined,
228
- config: RuntimeConfig
229
- ): Promise<RenovateBabysitResult | null> {
230
- if (!canSleepWithinBudget(deps, ciBudgetStartedAtMs, config)) {
231
- return failure("budget_exhausted", deps, startMs, counters, {
232
- detail: "remaining CI babysit budget is shorter than the polling interval",
233
- });
234
- }
235
- await deps.sleep(config.pollIntervalMs);
236
- return null;
237
- }
238
-
239
- async function fetchCiObservation(
240
- deps: RenovateBabysitDeps,
241
- expectedHeadSha: string,
242
- startMs: number,
243
- counters: Counters
244
- ): Promise<CiObservation | RenovateBabysitResult> {
245
- try {
246
- const observation = await deps.fetchCiState(expectedHeadSha);
247
- counters.ciPolls += 1;
248
- if (
249
- observation.headSha !== undefined &&
250
- observation.headSha.trim() !== "" &&
251
- observation.headSha !== expectedHeadSha
252
- ) {
253
- return failure("ci_query_failed", deps, startMs, counters, {
254
- observedHeadSha: observation.headSha,
255
- ciState: observation.ciState,
256
- detail: `CI result was for ${observation.headSha}, expected ${expectedHeadSha}`,
257
- });
258
- }
259
- return observation;
260
- } catch (error) {
261
- counters.ciPolls += 1;
262
- return failure("ci_query_failed", deps, startMs, counters, {
263
- detail: detailFromError(error),
264
- });
265
- }
266
- }
267
-
268
- async function fetchMergeAfterCiSuccess(
269
- deps: RenovateBabysitDeps,
270
- startMs: number,
271
- counters: Counters,
272
- expectedHeadSha: string,
273
- ciBudgetStartedAtMs: number,
274
- config: RuntimeConfig
275
- ): Promise<MergeObservation | RenovateBabysitResult> {
276
- if (!canContinueWithinBudget(deps, ciBudgetStartedAtMs, config)) {
277
- return failure("budget_exhausted", deps, startMs, counters);
278
- }
279
-
280
- counters.freshnessRechecks += 1;
281
- const observation = await fetchMergeObservation(deps, startMs, counters);
282
- if ("outcome" in observation) {
283
- return observation;
284
- }
285
-
286
- const headValidation = validateExpectedHead(
287
- observation,
288
- expectedHeadSha,
289
- deps,
290
- startMs,
291
- counters
292
- );
293
- return headValidation ?? observation;
294
- }
295
-
296
- function isFailureResult(
297
- value: MergeObservation | RenovateBabysitResult
298
- ): value is RenovateBabysitResult {
299
- return "outcome" in value;
300
- }
301
-
302
- function isNonCleanState(mergeState: MergeState): boolean {
303
- return mergeState !== "CLEAN" && mergeState !== "UNKNOWN" && mergeState !== "BLOCKED";
304
- }
305
-
306
- function handleUnknownObservation(
307
- observation: MergeObservation,
308
- state: PollState,
309
- deps: RenovateBabysitDeps,
310
- startMs: number,
311
- counters: Counters,
312
- config: RuntimeConfig
313
- ): RenovateBabysitResult | null {
314
- state.consecutiveUnknown += 1;
315
- if (state.consecutiveUnknown >= config.unknownMax) {
316
- return failure("unknown_exhausted", deps, startMs, counters, {
317
- observedHeadSha: observation.headSha,
318
- mergeState: observation.mergeState,
319
- });
320
- }
321
- return null;
322
- }
323
-
324
- async function handleCiForBlocked(
325
- deps: RenovateBabysitDeps,
326
- startMs: number,
327
- counters: Counters,
328
- state: PollState,
329
- expectedHeadSha: string,
330
- config: RuntimeConfig,
331
- ciBudgetStartedAtMs: number
332
- ): Promise<{ continuePolling: true } | RenovateBabysitResult> {
333
- if (!canContinueWithinBudget(deps, ciBudgetStartedAtMs, config)) {
334
- return failure("budget_exhausted", deps, startMs, counters);
335
- }
336
-
337
- const ciObservation = await fetchCiObservation(deps, expectedHeadSha, startMs, counters);
338
- if ("outcome" in ciObservation) {
339
- return ciObservation;
340
- }
341
-
342
- switch (ciObservation.ciState) {
343
- case "pending":
344
- case "not_found":
345
- return { continuePolling: true };
346
- case "failure":
347
- return failure("ci_failed", deps, startMs, counters, {
348
- ciState: ciObservation.ciState,
349
- detail: ciObservation.detail,
350
- });
351
- case "ambiguous":
352
- return failure("ci_query_failed", deps, startMs, counters, {
353
- ciState: ciObservation.ciState,
354
- detail: ciObservation.detail,
355
- });
356
- case "success": {
357
- const followUpObservation = await fetchMergeAfterCiSuccess(
358
- deps,
359
- startMs,
360
- counters,
361
- expectedHeadSha,
362
- ciBudgetStartedAtMs,
363
- config
364
- );
365
- if (isFailureResult(followUpObservation)) {
366
- return followUpObservation;
367
- }
368
- if (followUpObservation.mergeState === "CLEAN") {
369
- return clean(followUpObservation, deps, startMs, counters);
370
- }
371
- if (isNonCleanState(followUpObservation.mergeState)) {
372
- return failure("non_clean", deps, startMs, counters, {
373
- observedHeadSha: followUpObservation.headSha,
374
- mergeState: followUpObservation.mergeState,
375
- detail: followUpObservation.detail,
376
- });
377
- }
378
- if (followUpObservation.mergeState === "UNKNOWN") {
379
- const unknownFailure = handleUnknownObservation(
380
- followUpObservation,
381
- state,
382
- deps,
383
- startMs,
384
- counters,
385
- config
386
- );
387
- if (unknownFailure) {
388
- return unknownFailure;
389
- }
390
- } else {
391
- state.consecutiveUnknown = 0;
392
- }
393
- return { continuePolling: true };
394
- }
395
- }
396
- }
397
-
398
- export async function runRenovateBabysit(
399
- options: RenovateBabysitOptions,
400
- deps: RenovateBabysitDeps
401
- ): Promise<RenovateBabysitResult> {
402
- const config = runtimeConfig(options.config);
403
- const expectedHeadSha = options.expectedHeadSha.trim();
404
- const startMs = deps.now();
405
- const counters: Counters = { freshnessRechecks: 0, ciPolls: 0 };
406
-
407
- if (expectedHeadSha === "") {
408
- return failure("merge_query_failed", deps, startMs, counters, {
409
- detail: "expectedHeadSha is required",
410
- });
411
- }
412
-
413
- const state: PollState = { consecutiveUnknown: 0 };
414
- let ciBudgetStartedAtMs: number | undefined;
415
- let hasObservedMergeState = false;
416
-
417
- while (true) {
418
- if (!canContinueWithinBudget(deps, ciBudgetStartedAtMs, config)) {
419
- return failure("budget_exhausted", deps, startMs, counters);
420
- }
421
-
422
- if (hasObservedMergeState) {
423
- counters.freshnessRechecks += 1;
424
- }
425
-
426
- const observation = await fetchMergeObservation(deps, startMs, counters);
427
- if (isFailureResult(observation)) {
428
- return observation;
429
- }
430
- hasObservedMergeState = true;
431
-
432
- const headValidation = validateExpectedHead(
433
- observation,
434
- expectedHeadSha,
435
- deps,
436
- startMs,
437
- counters
438
- );
439
- if (headValidation) {
440
- return headValidation;
441
- }
442
-
443
- switch (observation.mergeState) {
444
- case "CLEAN":
445
- return clean(observation, deps, startMs, counters);
446
- case "UNKNOWN": {
447
- const unknownFailure = handleUnknownObservation(
448
- observation,
449
- state,
450
- deps,
451
- startMs,
452
- counters,
453
- config
454
- );
455
- if (unknownFailure) {
456
- return unknownFailure;
457
- }
458
- const sleepFailure = await sleepBeforeNextObservation(
459
- deps,
460
- startMs,
461
- counters,
462
- ciBudgetStartedAtMs,
463
- config
464
- );
465
- if (sleepFailure) {
466
- return sleepFailure;
467
- }
468
- break;
469
- }
470
- case "BLOCKED": {
471
- state.consecutiveUnknown = 0;
472
- ciBudgetStartedAtMs ??= deps.now();
473
- const ciResult = await handleCiForBlocked(
474
- deps,
475
- startMs,
476
- counters,
477
- state,
478
- expectedHeadSha,
479
- config,
480
- ciBudgetStartedAtMs
481
- );
482
- if ("outcome" in ciResult) {
483
- return ciResult;
484
- }
485
- const sleepFailure = await sleepBeforeNextObservation(
486
- deps,
487
- startMs,
488
- counters,
489
- ciBudgetStartedAtMs,
490
- config
491
- );
492
- if (sleepFailure) {
493
- return sleepFailure;
494
- }
495
- break;
496
- }
497
- default:
498
- return failure("non_clean", deps, startMs, counters, {
499
- observedHeadSha: observation.headSha,
500
- mergeState: observation.mergeState,
501
- detail: observation.detail,
502
- });
503
- }
504
- }
505
- }
506
-
507
- export type GhCheckRun = {
508
- name?: string;
509
- status?: string;
510
- conclusion?: string | null;
511
- head_sha?: string;
512
- };
513
-
514
- export type GhCheckRunsResponse = {
515
- check_runs?: GhCheckRun[];
516
- };
517
-
518
- export type GhCommitStatus = {
519
- context?: string;
520
- state?: string;
521
- sha?: string;
522
- };
523
-
524
- export type GhCommitStatusResponse = {
525
- statuses?: GhCommitStatus[];
526
- sha?: string;
527
- };
528
-
529
- function isRequiredPrCiName(name: string | undefined): boolean {
530
- return name === "test" || name === "CI / test";
531
- }
532
-
533
- function mergeCiStates(states: CiState[]): CiState {
534
- if (states.length === 0) {
535
- return "not_found";
536
- }
537
- if (states.includes("ambiguous")) {
538
- return "ambiguous";
539
- }
540
- if (states.includes("failure")) {
541
- return "failure";
542
- }
543
- if (states.includes("pending") || states.includes("not_found")) {
544
- return "pending";
545
- }
546
- return "success";
547
- }
548
-
549
- export function normalizeGhCheckRunsForExpectedHead(
550
- response: GhCheckRunsResponse,
551
- expectedHeadSha: string
552
- ): CiObservation {
553
- const runs = (response.check_runs ?? []).filter((run) => isRequiredPrCiName(run.name));
554
- if (runs.length === 0) {
555
- return { ciState: "not_found", headSha: expectedHeadSha };
556
- }
557
-
558
- const states: CiState[] = [];
559
- for (const run of runs) {
560
- if (run.head_sha !== undefined && run.head_sha !== expectedHeadSha) {
561
- return {
562
- ciState: "ambiguous",
563
- headSha: run.head_sha,
564
- detail: `check run ${run.name ?? "(unnamed)"} is for ${run.head_sha}, expected ${expectedHeadSha}`,
565
- };
566
- }
567
-
568
- const status = run.status?.toLowerCase();
569
- const conclusion = run.conclusion?.toLowerCase() ?? undefined;
570
-
571
- if (status !== "completed") {
572
- states.push(PENDING_CI_STATUSES.has(status ?? "") ? "pending" : "ambiguous");
573
- continue;
574
- }
575
- if (conclusion !== undefined && SUCCESSFUL_CI_STATES.has(conclusion)) {
576
- states.push("success");
577
- continue;
578
- }
579
- if (conclusion !== undefined && FAILURE_CI_STATES.has(conclusion)) {
580
- states.push("failure");
581
- continue;
582
- }
583
- states.push("ambiguous");
584
- }
585
-
586
- return { ciState: mergeCiStates(states), headSha: expectedHeadSha };
587
- }
588
-
589
- export function normalizeGhCommitStatusesForExpectedHead(
590
- response: GhCommitStatusResponse,
591
- expectedHeadSha: string
592
- ): CiObservation {
593
- if (response.sha !== undefined && response.sha !== expectedHeadSha) {
594
- return {
595
- ciState: "ambiguous",
596
- headSha: response.sha,
597
- detail: `commit status response is for ${response.sha}, expected ${expectedHeadSha}`,
598
- };
599
- }
600
-
601
- const statuses = (response.statuses ?? []).filter((status) => isRequiredPrCiName(status.context));
602
- if (statuses.length === 0) {
603
- return { ciState: "not_found", headSha: expectedHeadSha };
604
- }
605
-
606
- const states = statuses.map<CiState>((status) => {
607
- if (status.sha !== undefined && status.sha !== expectedHeadSha) {
608
- return "ambiguous";
609
- }
610
- switch (status.state?.toLowerCase()) {
611
- case "success":
612
- return "success";
613
- case "pending":
614
- return "pending";
615
- case "failure":
616
- case "error":
617
- return "failure";
618
- default:
619
- return "ambiguous";
620
- }
621
- });
622
-
623
- return { ciState: mergeCiStates(states), headSha: expectedHeadSha };
624
- }