@sous-io/sous 0.2.15 → 0.2.17
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/docs/markdown/commands.md +63 -16
- package/docs/markdown/repositories-authoring.md +52 -11
- package/docs/markdown/repositories-consuming.md +32 -1
- package/docs/markdown/repositories-file-formats.md +20 -0
- package/docs/markdown/repositories-providers.md +20 -10
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +8 -1
- package/src/commands/namespace/list.ts +42 -21
- package/src/commands/namespace/show.ts +32 -12
- package/src/commands/recipe/list.ts +40 -23
- package/src/commands/recipe/show.ts +28 -6
- package/src/commands/repo/list.ts +67 -10
- package/src/commands/repo/release.ts +41 -0
- package/src/commands/repo/search.ts +68 -15
- package/src/commands/repo/submit.ts +245 -35
- package/src/commands/subscription/list.ts +98 -19
- package/src/lib/build-preparation.ts +44 -1
- package/src/lib/repos/catalog-display.ts +101 -2
- package/src/lib/repos/catalog-inputs.ts +145 -17
- package/src/lib/repos/catalog.ts +92 -5
- package/src/lib/repos/formats/common.ts +20 -0
- package/src/lib/repos/formats/recipe-manifest.ts +7 -0
- package/src/lib/repos/formats/repo-manifest.ts +8 -0
- package/src/lib/repos/freshness.ts +56 -0
- package/src/lib/repos/providers/base.ts +33 -1
- package/src/lib/repos/providers/github.ts +276 -3
- package/src/lib/repos/providers/gitlab.ts +1 -0
- package/src/lib/repos/providers/http.ts +7 -2
- package/src/lib/repos/providers/index-cache.ts +56 -17
- package/src/lib/repos/providers/provider.ts +121 -3
- package/src/lib/repos/release/changelog.ts +448 -0
- package/src/lib/repos/release/git-state.ts +101 -15
- package/src/lib/repos/release/index.ts +2 -0
- package/src/lib/repos/release/submissions.ts +214 -0
- package/src/lib/repos/release/submit-checkout.ts +271 -0
- package/src/lib/repos/release/submit-questions.ts +153 -0
- package/src/lib/repos/release/submit-service.ts +581 -174
- package/src/lib/repos/subscription-service.ts +138 -9
- package/src/utils/flags.ts +24 -0
package/src/lib/repos/catalog.ts
CHANGED
|
@@ -78,6 +78,11 @@ export type CatalogInputs = {
|
|
|
78
78
|
lock: Lockfile;
|
|
79
79
|
/** The ref keys the project subscribes to: namespaces, and `namespace/recipe`. */
|
|
80
80
|
subscriptions: string[];
|
|
81
|
+
/**
|
|
82
|
+
* The repositories read from a linked working copy instead of the store, by
|
|
83
|
+
* short name, each with the checkout's path.
|
|
84
|
+
*/
|
|
85
|
+
linked?: Record<string, string>;
|
|
81
86
|
/**
|
|
82
87
|
* Reads one published recipe's manifest, when its files are on this machine.
|
|
83
88
|
* Returning undefined means "not available", and the recipe is described from
|
|
@@ -119,6 +124,11 @@ export type RecipeListing = {
|
|
|
119
124
|
latest?: string;
|
|
120
125
|
/** The version this project's lockfile pins, when it pins one. */
|
|
121
126
|
pinned?: string;
|
|
127
|
+
/**
|
|
128
|
+
* The linked checkout builds read this recipe from instead of the pinned
|
|
129
|
+
* version, when the project pins it and its repository is linked.
|
|
130
|
+
*/
|
|
131
|
+
linkedPath?: string;
|
|
122
132
|
/** True when the project subscribes to this recipe, or to the whole namespace holding it. */
|
|
123
133
|
subscribed: boolean;
|
|
124
134
|
/** The recipe's one-paragraph summary, when its index carries one. */
|
|
@@ -212,6 +222,11 @@ export type RecipeDetail = {
|
|
|
212
222
|
latest?: string;
|
|
213
223
|
/** The version this project's lockfile pins, when it pins one. */
|
|
214
224
|
pinned?: string;
|
|
225
|
+
/**
|
|
226
|
+
* The linked checkout builds read this recipe from instead of the pinned
|
|
227
|
+
* version, when the project pins it and its repository is linked.
|
|
228
|
+
*/
|
|
229
|
+
linkedPath?: string;
|
|
215
230
|
/** True when the project subscribes to this recipe, or to the whole namespace holding it. */
|
|
216
231
|
subscribed: boolean;
|
|
217
232
|
/**
|
|
@@ -275,7 +290,7 @@ export function listRecipes(inputs: CatalogInputs): RecipeListing[] {
|
|
|
275
290
|
|
|
276
291
|
for (const repo of inputs.repos) {
|
|
277
292
|
for (const key of Object.keys(repo.index.recipes)) {
|
|
278
|
-
listings.push(recipeListing(key, repo, inputs
|
|
293
|
+
listings.push(recipeListing(key, repo, inputs, subscriptions));
|
|
279
294
|
}
|
|
280
295
|
}
|
|
281
296
|
|
|
@@ -305,7 +320,7 @@ export function describeNamespace(inputs: CatalogInputs, ref: string): Namespace
|
|
|
305
320
|
...(declared.description === undefined ? {} : { description: declared.description }),
|
|
306
321
|
subscribed: coverageOf(found.namespace, found.repo.index, subscriptions),
|
|
307
322
|
recipes: recipeKeysIn(found.repo.index, found.namespace).map((key) =>
|
|
308
|
-
recipeListing(key, found.repo, inputs
|
|
323
|
+
recipeListing(key, found.repo, inputs, subscriptions)
|
|
309
324
|
),
|
|
310
325
|
};
|
|
311
326
|
}
|
|
@@ -320,7 +335,7 @@ export function describeNamespace(inputs: CatalogInputs, ref: string): Namespace
|
|
|
320
335
|
export function describeRecipe(inputs: CatalogInputs, ref: string): RecipeDetail {
|
|
321
336
|
const found = resolveRecipeRef(inputs, ref);
|
|
322
337
|
const subscriptions = new Set(inputs.subscriptions);
|
|
323
|
-
const listing = recipeListing(found.key, found.repo, inputs
|
|
338
|
+
const listing = recipeListing(found.key, found.repo, inputs, subscriptions);
|
|
324
339
|
const entry = found.repo.index.recipes[found.key]!;
|
|
325
340
|
|
|
326
341
|
const describing = listing.pinned ?? listing.latest;
|
|
@@ -355,6 +370,75 @@ export function describeRecipe(inputs: CatalogInputs, ref: string): RecipeDetail
|
|
|
355
370
|
};
|
|
356
371
|
}
|
|
357
372
|
|
|
373
|
+
/**
|
|
374
|
+
* The same inputs, narrowed to what this project has installed: each
|
|
375
|
+
* repository's index keeps only the recipes the lockfile pins from that
|
|
376
|
+
* repository, and only the namespaces holding one of them. Every listing and
|
|
377
|
+
* every ref lookup over the result therefore sees only installed recipes. A
|
|
378
|
+
* recipe's published version history is kept whole, so the installed version
|
|
379
|
+
* still sits among the others.
|
|
380
|
+
*
|
|
381
|
+
* narrowToInstalled(inputs)
|
|
382
|
+
* // -> the index of "r" holds "a/x" only, when the lockfile pins "a/x" from "r"
|
|
383
|
+
*
|
|
384
|
+
* @param inputs - The catalog's inputs.
|
|
385
|
+
*/
|
|
386
|
+
export function narrowToInstalled(inputs: CatalogInputs): CatalogInputs {
|
|
387
|
+
const repos = inputs.repos.map((repo) => {
|
|
388
|
+
const recipes = Object.fromEntries(
|
|
389
|
+
Object.entries(repo.index.recipes).filter(
|
|
390
|
+
([key]) => inputs.lock.recipes[key]?.repo === repo.name
|
|
391
|
+
)
|
|
392
|
+
);
|
|
393
|
+
const held = new Set(Object.keys(recipes).map((key) => key.slice(0, key.indexOf("/"))));
|
|
394
|
+
const namespaces = Object.fromEntries(
|
|
395
|
+
Object.entries(repo.index.namespaces).filter(([namespace]) => held.has(namespace))
|
|
396
|
+
);
|
|
397
|
+
return { ...repo, index: { ...repo.index, recipes, namespaces } };
|
|
398
|
+
});
|
|
399
|
+
return { ...inputs, repos };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Looks a ref up among the installed recipes only, so an ambiguity between an
|
|
404
|
+
* installed recipe and one the project does not use settles itself. A ref that
|
|
405
|
+
* names something published but not installed is an error saying exactly that,
|
|
406
|
+
* rather than the "no repository publishes" error the narrowed lookup alone
|
|
407
|
+
* would raise.
|
|
408
|
+
*
|
|
409
|
+
* describeInstalled(inputs, "workflow/task-files", describeRecipe, "recipe")
|
|
410
|
+
* // -> the recipe, when the lockfile pins it; otherwise an error saying the
|
|
411
|
+
* // project has not installed it
|
|
412
|
+
*
|
|
413
|
+
* @param inputs - The catalog's inputs, not yet narrowed.
|
|
414
|
+
* @param ref - The ref being looked up.
|
|
415
|
+
* @param describe - The lookup to run: `describeNamespace` or `describeRecipe`.
|
|
416
|
+
* @param what - What the ref names, for the error.
|
|
417
|
+
*/
|
|
418
|
+
export function describeInstalled<T>(
|
|
419
|
+
inputs: CatalogInputs,
|
|
420
|
+
ref: string,
|
|
421
|
+
describe: (inputs: CatalogInputs, ref: string) => T,
|
|
422
|
+
what: "namespace" | "recipe"
|
|
423
|
+
): T {
|
|
424
|
+
try {
|
|
425
|
+
return describe(narrowToInstalled(inputs), ref);
|
|
426
|
+
} catch {
|
|
427
|
+
// The full lookup either explains the ref better (ambiguous, unknown) or
|
|
428
|
+
// proves it is published and simply not installed.
|
|
429
|
+
describe(inputs, ref);
|
|
430
|
+
throw what === "namespace"
|
|
431
|
+
? new ConfigError(
|
|
432
|
+
`This project has installed no recipe from the namespace '${ref}', so there is ` +
|
|
433
|
+
`nothing to show with '--installed'.`
|
|
434
|
+
)
|
|
435
|
+
: new ConfigError(
|
|
436
|
+
`This project has not installed the recipe '${ref}', so there is nothing to ` +
|
|
437
|
+
`show with '--installed'.`
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
358
442
|
// --- Resolving a ref ----------------------------------------------------------------------------
|
|
359
443
|
|
|
360
444
|
/** One namespace a ref resolved to. */
|
|
@@ -488,15 +572,16 @@ function coverageOf(
|
|
|
488
572
|
*
|
|
489
573
|
* @param key - The recipe key.
|
|
490
574
|
* @param repo - The repository publishing it.
|
|
491
|
-
* @param
|
|
575
|
+
* @param inputs - The lockfile and the linked repositories.
|
|
492
576
|
* @param subscriptions - The ref keys the project subscribes to.
|
|
493
577
|
*/
|
|
494
578
|
function recipeListing(
|
|
495
579
|
key: string,
|
|
496
580
|
repo: CatalogRepo,
|
|
497
|
-
|
|
581
|
+
inputs: Pick<CatalogInputs, "lock" | "linked">,
|
|
498
582
|
subscriptions: Set<string>
|
|
499
583
|
): RecipeListing {
|
|
584
|
+
const lock = inputs.lock;
|
|
500
585
|
const entry = repo.index.recipes[key]!;
|
|
501
586
|
const namespace = key.slice(0, key.indexOf("/"));
|
|
502
587
|
const name = key.slice(namespace.length + 1);
|
|
@@ -507,6 +592,7 @@ function recipeListing(
|
|
|
507
592
|
// the recipe came from this repository.
|
|
508
593
|
const locked = lock.recipes[key];
|
|
509
594
|
const pinned = locked !== undefined && locked.repo === repo.name ? locked.version : undefined;
|
|
595
|
+
const linkedPath = pinned === undefined ? undefined : inputs.linked?.[repo.name];
|
|
510
596
|
|
|
511
597
|
return {
|
|
512
598
|
key,
|
|
@@ -515,6 +601,7 @@ function recipeListing(
|
|
|
515
601
|
repo: repo.name,
|
|
516
602
|
...(latest === undefined ? {} : { latest }),
|
|
517
603
|
...(pinned === undefined ? {} : { pinned }),
|
|
604
|
+
...(linkedPath === undefined ? {} : { linkedPath }),
|
|
518
605
|
subscribed: subscriptions.has(key) || subscriptions.has(namespace),
|
|
519
606
|
...(entry.description === undefined ? {} : { description: entry.description }),
|
|
520
607
|
};
|
|
@@ -313,6 +313,26 @@ export function extensibleObject<Shape extends z.ZodRawShape>(shape: Shape) {
|
|
|
313
313
|
return z.preprocess(stripExtensionKeys, z.strictObject(shape));
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
/**
|
|
317
|
+
* The `submissions` block a repo manifest and a recipe manifest may both carry:
|
|
318
|
+
* whether the recipes it covers take proposed changes, and where to send a
|
|
319
|
+
* change instead when they do not. On a repo manifest it covers every recipe;
|
|
320
|
+
* on a recipe manifest it covers that recipe, and wins over the repository's.
|
|
321
|
+
*
|
|
322
|
+
* submissions:
|
|
323
|
+
* allowed: false
|
|
324
|
+
* instead: Propose changes in sous-io/sous, under recipes/core/sous-skills/.
|
|
325
|
+
*/
|
|
326
|
+
export const submissionsSchema = extensibleObject({
|
|
327
|
+
/** Whether a proposed change to the covered recipes is accepted. Defaults to true. */
|
|
328
|
+
allowed: z.boolean().default(true),
|
|
329
|
+
/** Where a change should go instead, printed when one is proposed anyway. */
|
|
330
|
+
instead: z.string().min(1, "must not be empty").optional(),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/** A validated `submissions` block. */
|
|
334
|
+
export type Submissions = z.infer<typeof submissionsSchema>;
|
|
335
|
+
|
|
316
336
|
// --- Error reporting ----------------------------------------------------------------------------
|
|
317
337
|
|
|
318
338
|
/** Renders a zod issue path (`["variables",0,"name"]`) as `variables[0].name`. */
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
recipeNameSchema,
|
|
35
35
|
relativePathSchema,
|
|
36
36
|
semverVersionSchema,
|
|
37
|
+
submissionsSchema,
|
|
37
38
|
variableNameSchema,
|
|
38
39
|
} from "./common.js";
|
|
39
40
|
import { parseDependencyRef } from "../ref.js";
|
|
@@ -301,6 +302,12 @@ export const recipeManifestSchema = extensibleObject({
|
|
|
301
302
|
version: semverVersionSchema,
|
|
302
303
|
/** One-paragraph summary, shown by `sous repo search` and `sous repo list`. */
|
|
303
304
|
description: z.string().optional(),
|
|
305
|
+
/**
|
|
306
|
+
* Whether this recipe takes proposed changes, winning over the repository's
|
|
307
|
+
* own `submissions` block. A recipe whose files are copied in from somewhere
|
|
308
|
+
* else sets `allowed: false` and says where to go instead.
|
|
309
|
+
*/
|
|
310
|
+
submissions: submissionsSchema.optional(),
|
|
304
311
|
/**
|
|
305
312
|
* Build dependencies: fetched and addressable here, but not added to the
|
|
306
313
|
* project. Each entry is a bare ref naming a sibling recipe in this same
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
parseFormat,
|
|
18
18
|
relativePathSchema,
|
|
19
19
|
repoNameSchema,
|
|
20
|
+
submissionsSchema,
|
|
20
21
|
} from "./common.js";
|
|
21
22
|
|
|
22
23
|
/** A namespace declaration. Namespaces group recipes and are not versioned. */
|
|
@@ -48,6 +49,13 @@ export const repoManifestSchema = extensibleObject({
|
|
|
48
49
|
* contributor is never left without a route.
|
|
49
50
|
*/
|
|
50
51
|
contribute: z.string().min(1, "must not be empty").optional(),
|
|
52
|
+
/**
|
|
53
|
+
* Whether the repository's recipes take proposed changes. A recipe's own
|
|
54
|
+
* `submissions` block wins over this one. `sous repo submit` warns before
|
|
55
|
+
* proposing a change to a recipe that does not, and `sous repo release
|
|
56
|
+
* --check` fails a pull request that changes one.
|
|
57
|
+
*/
|
|
58
|
+
submissions: submissionsSchema.optional(),
|
|
51
59
|
/** Every namespace the repo publishes, keyed by namespace name. */
|
|
52
60
|
namespaces: z.record(namespaceNameSchema, repoNamespaceSchema),
|
|
53
61
|
/**
|
|
@@ -206,3 +206,59 @@ export function effectiveRangeForHolders(
|
|
|
206
206
|
const combined = constraints.join(" ");
|
|
207
207
|
return semver.validRange(combined) === null ? undefined : combined;
|
|
208
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* How long a build waits for a repository that it is only checking so it can
|
|
212
|
+
* say whether a newer version exists: three seconds. Past that the check is
|
|
213
|
+
* abandoned, quietly, and the build goes on with what the cache already knew.
|
|
214
|
+
*/
|
|
215
|
+
export const NEWER_VERSION_CHECK_TIMEOUT_MS = 3000;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Runs a piece of upstream work with a deadline. The work is handed an abort
|
|
219
|
+
* signal that fires at the deadline, so a request that honors it is cancelled
|
|
220
|
+
* rather than left to keep the process alive; either way the returned promise
|
|
221
|
+
* rejects at the deadline, and the timer never holds the process open itself.
|
|
222
|
+
*
|
|
223
|
+
* await withDeadline((signal) => fetchSomething(signal), 3000);
|
|
224
|
+
* // -> the result, or an error saying the repository did not answer in time
|
|
225
|
+
*
|
|
226
|
+
* @param work - The work to run, given the signal that cancels it.
|
|
227
|
+
* @param milliseconds - How long to wait.
|
|
228
|
+
*/
|
|
229
|
+
export async function withDeadline<T>(
|
|
230
|
+
work: (signal: AbortSignal) => Promise<T>,
|
|
231
|
+
milliseconds: number
|
|
232
|
+
): Promise<T> {
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
+
|
|
236
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
237
|
+
timer = setTimeout(() => {
|
|
238
|
+
controller.abort();
|
|
239
|
+
reject(
|
|
240
|
+
new Error(
|
|
241
|
+
`The repository did not answer within ${formatSeconds(milliseconds)}, so sous ` +
|
|
242
|
+
`stopped waiting for it.`
|
|
243
|
+
)
|
|
244
|
+
);
|
|
245
|
+
}, milliseconds);
|
|
246
|
+
timer.unref?.();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
return await Promise.race([work(controller.signal), deadline]);
|
|
251
|
+
} finally {
|
|
252
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A duration in plain words, such as "3 seconds" or "1 second".
|
|
258
|
+
*
|
|
259
|
+
* @param milliseconds - The duration.
|
|
260
|
+
*/
|
|
261
|
+
function formatSeconds(milliseconds: number): string {
|
|
262
|
+
const seconds = Math.round((milliseconds / 1000) * 10) / 10;
|
|
263
|
+
return seconds === 1 ? "1 second" : `${seconds} seconds`;
|
|
264
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* declare the `submit` feature (the local one, for instance) inherits four
|
|
11
11
|
* methods that raise a ConfigError naming the provider and what was asked of
|
|
12
12
|
* it, so a caller that skips the feature check gets a sentence rather than a
|
|
13
|
-
* `TypeError`.
|
|
13
|
+
* `TypeError`. The three calls behind the `proposals` feature are refused the
|
|
14
|
+
* same way.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { ConfigError } from "../../errors.js";
|
|
@@ -26,6 +27,10 @@ import type {
|
|
|
26
27
|
ChangeProposal,
|
|
27
28
|
FetchedIndex,
|
|
28
29
|
ForkedRepo,
|
|
30
|
+
ProposalQuery,
|
|
31
|
+
ProposalStatus,
|
|
32
|
+
ProposalSummary,
|
|
33
|
+
ProposalUpdate,
|
|
29
34
|
ProposedChange,
|
|
30
35
|
ProviderCli,
|
|
31
36
|
ProviderFeature,
|
|
@@ -187,6 +192,33 @@ export abstract class ProviderBase implements RepoProvider {
|
|
|
187
192
|
throw this.unsupported("submit", "propose a change to it");
|
|
188
193
|
}
|
|
189
194
|
|
|
195
|
+
// --- Proposals after the fact, refused unless a provider overrides them ----
|
|
196
|
+
|
|
197
|
+
async findProposal(
|
|
198
|
+
_repo: CanonicalRepo,
|
|
199
|
+
_query: ProposalQuery,
|
|
200
|
+
_options: ProviderOptions = {}
|
|
201
|
+
): Promise<ProposalSummary | undefined> {
|
|
202
|
+
throw this.unsupported("proposals", "look for a proposal that is already open");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async proposalStatus(
|
|
206
|
+
_repo: CanonicalRepo,
|
|
207
|
+
_id: string,
|
|
208
|
+
_options: ProviderOptions = {}
|
|
209
|
+
): Promise<ProposalStatus> {
|
|
210
|
+
throw this.unsupported("proposals", "report where a proposal stands");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async updateProposal(
|
|
214
|
+
_repo: CanonicalRepo,
|
|
215
|
+
_id: string,
|
|
216
|
+
_update: ProposalUpdate,
|
|
217
|
+
_options: ProviderOptions = {}
|
|
218
|
+
): Promise<ProposedChange> {
|
|
219
|
+
throw this.unsupported("proposals", "change a proposal's title or body");
|
|
220
|
+
}
|
|
221
|
+
|
|
190
222
|
/**
|
|
191
223
|
* The refusal a provider gives when it is asked for something it never
|
|
192
224
|
* claimed. It names the provider and the feature, so the caller learns why
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* request opened by `gh pr create`, a contributor without push permission works
|
|
13
13
|
* through a fork made by `gh repo fork`, and both are reported back as plain
|
|
14
14
|
* data, so the service that sequences them never learns a GitHub-shaped fact.
|
|
15
|
+
* Finding a pull request again, reporting where it stands and replacing its
|
|
16
|
+
* text go through `gh pr list`, `gh pr view` and `gh pr edit` the same way.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
import { ConfigError } from "../../errors.js";
|
|
@@ -28,6 +30,13 @@ import {
|
|
|
28
30
|
type ChangeProposal,
|
|
29
31
|
type FetchedIndex,
|
|
30
32
|
type ForkedRepo,
|
|
33
|
+
type ProposalChecks,
|
|
34
|
+
type ProposalQuery,
|
|
35
|
+
type ProposalReview,
|
|
36
|
+
type ProposalState,
|
|
37
|
+
type ProposalStatus,
|
|
38
|
+
type ProposalSummary,
|
|
39
|
+
type ProposalUpdate,
|
|
31
40
|
type ProposedChange,
|
|
32
41
|
type ProviderCli,
|
|
33
42
|
type ProviderFeature,
|
|
@@ -59,10 +68,10 @@ export class GithubProvider extends ProviderBase {
|
|
|
59
68
|
readonly id = "github" as const;
|
|
60
69
|
|
|
61
70
|
/**
|
|
62
|
-
* Reads the index and recipe subtrees,
|
|
63
|
-
*
|
|
71
|
+
* Reads the index and recipe subtrees, proposes a change through the GitHub
|
|
72
|
+
* CLI ('gh'), and finds, reports on and updates that pull request afterwards.
|
|
64
73
|
*/
|
|
65
|
-
readonly features: ProviderFeature[] = ["fetch", "submit"];
|
|
74
|
+
readonly features: ProviderFeature[] = ["fetch", "submit", "proposals"];
|
|
66
75
|
|
|
67
76
|
/** The command line tool the write path is built on. */
|
|
68
77
|
readonly cli: ProviderCli = {
|
|
@@ -113,6 +122,7 @@ export class GithubProvider extends ProviderBase {
|
|
|
113
122
|
...(options.fetchImpl === undefined
|
|
114
123
|
? {}
|
|
115
124
|
: { fetchImpl: options.fetchImpl as FetchLike }),
|
|
125
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
116
126
|
label: "repo index",
|
|
117
127
|
});
|
|
118
128
|
|
|
@@ -291,4 +301,267 @@ export class GithubProvider extends ProviderBase {
|
|
|
291
301
|
}
|
|
292
302
|
return { url, detail: `The ${this.proposalNoun} is at ${url}.` };
|
|
293
303
|
}
|
|
304
|
+
|
|
305
|
+
// --- Proposals after the fact ------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The pull request a branch was pushed for. GitHub lists pull requests by
|
|
309
|
+
* head branch name alone, so the list is narrowed here by where the branch
|
|
310
|
+
* lives: the repository itself, or the contributor's fork. When a branch has
|
|
311
|
+
* had several, the open one wins, and otherwise the newest.
|
|
312
|
+
*
|
|
313
|
+
* @param repo - The canonicalized repository the proposal targets.
|
|
314
|
+
* @param query - The branch, and whether it lives on a fork.
|
|
315
|
+
* @param options - Subprocess runner and working directory overrides.
|
|
316
|
+
*/
|
|
317
|
+
async findProposal(
|
|
318
|
+
repo: CanonicalRepo,
|
|
319
|
+
query: ProposalQuery,
|
|
320
|
+
options: ProviderOptions = {}
|
|
321
|
+
): Promise<ProposalSummary | undefined> {
|
|
322
|
+
const forkOwner = query.fromFork
|
|
323
|
+
? (query.forkOwner ?? (await this.signedInLogin(options)))
|
|
324
|
+
: undefined;
|
|
325
|
+
|
|
326
|
+
const listed = await this.ghJson<GhPullRequest[]>(
|
|
327
|
+
[
|
|
328
|
+
"pr",
|
|
329
|
+
"list",
|
|
330
|
+
"--repo",
|
|
331
|
+
`${repo.owner}/${repo.name}`,
|
|
332
|
+
"--head",
|
|
333
|
+
query.branch,
|
|
334
|
+
"--state",
|
|
335
|
+
"all",
|
|
336
|
+
"--limit",
|
|
337
|
+
"50",
|
|
338
|
+
"--json",
|
|
339
|
+
"number,url,state,title,isDraft,baseRefName,headRepositoryOwner,isCrossRepository",
|
|
340
|
+
],
|
|
341
|
+
"pr list",
|
|
342
|
+
options
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const mine = listed.filter((entry) =>
|
|
346
|
+
query.fromFork
|
|
347
|
+
? entry.isCrossRepository === true && entry.headRepositoryOwner?.login === forkOwner
|
|
348
|
+
: entry.isCrossRepository !== true
|
|
349
|
+
);
|
|
350
|
+
if (mine.length === 0) return undefined;
|
|
351
|
+
|
|
352
|
+
const open = mine.find((entry) => entry.state === "OPEN");
|
|
353
|
+
const chosen = open ?? [...mine].sort((a, b) => b.number - a.number)[0]!;
|
|
354
|
+
return summarizePullRequest(chosen);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Where one pull request stands: its state, its review decision, and how
|
|
359
|
+
* its checks are going, counted.
|
|
360
|
+
*
|
|
361
|
+
* @param repo - The canonicalized repository the proposal targets.
|
|
362
|
+
* @param id - The pull request number.
|
|
363
|
+
* @param options - Subprocess runner and working directory overrides.
|
|
364
|
+
*/
|
|
365
|
+
async proposalStatus(
|
|
366
|
+
repo: CanonicalRepo,
|
|
367
|
+
id: string,
|
|
368
|
+
options: ProviderOptions = {}
|
|
369
|
+
): Promise<ProposalStatus> {
|
|
370
|
+
const viewed = await this.ghJson<GhPullRequest>(
|
|
371
|
+
[
|
|
372
|
+
"pr",
|
|
373
|
+
"view",
|
|
374
|
+
id,
|
|
375
|
+
"--repo",
|
|
376
|
+
`${repo.owner}/${repo.name}`,
|
|
377
|
+
"--json",
|
|
378
|
+
"number,url,state,title,isDraft,baseRefName,reviewDecision,statusCheckRollup,mergeable",
|
|
379
|
+
],
|
|
380
|
+
"pr view",
|
|
381
|
+
options
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
const review = reviewFrom(viewed.reviewDecision);
|
|
385
|
+
const checks = checksFrom(viewed.statusCheckRollup);
|
|
386
|
+
const mergeable =
|
|
387
|
+
viewed.mergeable === "MERGEABLE"
|
|
388
|
+
? true
|
|
389
|
+
: viewed.mergeable === "CONFLICTING"
|
|
390
|
+
? false
|
|
391
|
+
: undefined;
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
proposal: summarizePullRequest(viewed),
|
|
395
|
+
...(review === undefined ? {} : { review }),
|
|
396
|
+
...(checks === undefined ? {} : { checks }),
|
|
397
|
+
...(mergeable === undefined ? {} : { mergeable }),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Replaces a pull request's title, its body, or both.
|
|
403
|
+
*
|
|
404
|
+
* @param repo - The canonicalized repository the proposal targets.
|
|
405
|
+
* @param id - The pull request number.
|
|
406
|
+
* @param update - What to replace.
|
|
407
|
+
* @param options - Subprocess runner and working directory overrides.
|
|
408
|
+
*/
|
|
409
|
+
async updateProposal(
|
|
410
|
+
repo: CanonicalRepo,
|
|
411
|
+
id: string,
|
|
412
|
+
update: ProposalUpdate,
|
|
413
|
+
options: ProviderOptions = {}
|
|
414
|
+
): Promise<ProposedChange> {
|
|
415
|
+
const args = ["pr", "edit", id, "--repo", `${repo.owner}/${repo.name}`];
|
|
416
|
+
if (update.title !== undefined) args.push("--title", update.title);
|
|
417
|
+
if (update.body !== undefined) args.push("--body", update.body);
|
|
418
|
+
|
|
419
|
+
const result = await this.runCommand(this.cli.command, args, options);
|
|
420
|
+
if (result.code !== 0) {
|
|
421
|
+
const reported = result.stderr.trim() || result.stdout.trim();
|
|
422
|
+
throw new ConfigError(
|
|
423
|
+
`'${this.cli.command} pr edit' did not succeed, so the ${this.proposalNoun} kept its ` +
|
|
424
|
+
`title and body.` +
|
|
425
|
+
(reported.length === 0 ? "" : `\n ${reported}`)
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const url = firstUrlIn(result.stdout);
|
|
430
|
+
return url === undefined
|
|
431
|
+
? { detail: `The ${this.proposalNoun} was updated.` }
|
|
432
|
+
: { url, detail: `The ${this.proposalNoun} at ${url} was updated.` };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The login of the account `gh` is signed in as, which is the owner of any
|
|
437
|
+
* fork sous made for the contributor.
|
|
438
|
+
*
|
|
439
|
+
* @param options - Subprocess runner and working directory overrides.
|
|
440
|
+
*/
|
|
441
|
+
private async signedInLogin(options: ProviderOptions): Promise<string> {
|
|
442
|
+
const who = await this.capturedOutput(
|
|
443
|
+
this.cli.command,
|
|
444
|
+
["api", "user", "--jq", ".login"],
|
|
445
|
+
options
|
|
446
|
+
);
|
|
447
|
+
if (who === undefined || who.trim().length === 0) {
|
|
448
|
+
throw new ConfigError(
|
|
449
|
+
"Sous could not read your GitHub login from " +
|
|
450
|
+
`'${this.cli.command} api user', so it cannot tell which fork a ${this.proposalNoun} ` +
|
|
451
|
+
"would come from."
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return who.trim();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Runs a `gh` command that prints JSON, and parses what it printed.
|
|
459
|
+
*
|
|
460
|
+
* @param args - The arguments, ending with the `--json` field list.
|
|
461
|
+
* @param what - The subcommand, as it is named in a failure.
|
|
462
|
+
* @param options - Subprocess runner and working directory overrides.
|
|
463
|
+
*/
|
|
464
|
+
private async ghJson<T>(args: string[], what: string, options: ProviderOptions): Promise<T> {
|
|
465
|
+
const result = await this.runCommand(this.cli.command, args, options);
|
|
466
|
+
if (result.code !== 0) {
|
|
467
|
+
const reported = result.stderr.trim() || result.stdout.trim();
|
|
468
|
+
throw new ConfigError(
|
|
469
|
+
`'${this.cli.command} ${what}' did not succeed.` +
|
|
470
|
+
(reported.length === 0 ? "" : `\n ${reported}`)
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
return JSON.parse(result.stdout) as T;
|
|
475
|
+
} catch {
|
|
476
|
+
throw new ConfigError(
|
|
477
|
+
`'${this.cli.command} ${what}' printed something that is not JSON, so sous cannot read ` +
|
|
478
|
+
`the ${this.proposalNoun} it describes.`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// --- What gh prints, and how it maps onto plain data ---------------------------------------------
|
|
485
|
+
|
|
486
|
+
/** The fields sous asks `gh` for, as it prints them. */
|
|
487
|
+
type GhPullRequest = {
|
|
488
|
+
number: number;
|
|
489
|
+
url?: string;
|
|
490
|
+
state?: string;
|
|
491
|
+
title?: string;
|
|
492
|
+
isDraft?: boolean;
|
|
493
|
+
baseRefName?: string;
|
|
494
|
+
headRepositoryOwner?: { login?: string } | null;
|
|
495
|
+
isCrossRepository?: boolean;
|
|
496
|
+
reviewDecision?: string | null;
|
|
497
|
+
statusCheckRollup?: GhCheck[] | null;
|
|
498
|
+
mergeable?: string;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
/** One entry of a pull request's status check rollup: a check run or a commit status. */
|
|
502
|
+
type GhCheck = {
|
|
503
|
+
__typename?: string;
|
|
504
|
+
status?: string;
|
|
505
|
+
conclusion?: string | null;
|
|
506
|
+
state?: string;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
/** A pull request's state, in the words every provider shares. */
|
|
510
|
+
function stateFrom(state: string | undefined): ProposalState {
|
|
511
|
+
if (state === "MERGED") return "merged";
|
|
512
|
+
if (state === "CLOSED") return "closed";
|
|
513
|
+
return "open";
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The plain summary of a pull request.
|
|
518
|
+
*
|
|
519
|
+
* @param entry - What `gh` printed for it.
|
|
520
|
+
*/
|
|
521
|
+
function summarizePullRequest(entry: GhPullRequest): ProposalSummary {
|
|
522
|
+
return {
|
|
523
|
+
id: String(entry.number),
|
|
524
|
+
...(entry.url === undefined ? {} : { url: entry.url }),
|
|
525
|
+
state: stateFrom(entry.state),
|
|
526
|
+
title: entry.title ?? "",
|
|
527
|
+
draft: entry.isDraft === true,
|
|
528
|
+
...(entry.baseRefName === undefined ? {} : { base: entry.baseRefName }),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** GitHub's review decision, in the words every provider shares. */
|
|
533
|
+
function reviewFrom(decision: string | null | undefined): ProposalReview | undefined {
|
|
534
|
+
if (decision === "APPROVED") return "approved";
|
|
535
|
+
if (decision === "CHANGES_REQUESTED") return "changes requested";
|
|
536
|
+
if (decision === "REVIEW_REQUIRED") return "review required";
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Check runs whose conclusion counts as passing. */
|
|
541
|
+
const PASSING_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Counts a status check rollup into passed, failed and pending. A check run
|
|
545
|
+
* that has not completed is pending; a commit status reports its state directly.
|
|
546
|
+
*
|
|
547
|
+
* @param rollup - What `gh` printed as the rollup, when it printed one.
|
|
548
|
+
*/
|
|
549
|
+
function checksFrom(rollup: GhCheck[] | null | undefined): ProposalChecks | undefined {
|
|
550
|
+
if (rollup === null || rollup === undefined || rollup.length === 0) return undefined;
|
|
551
|
+
const counts: ProposalChecks = { passed: 0, failed: 0, pending: 0 };
|
|
552
|
+
for (const check of rollup) {
|
|
553
|
+
const isStatus =
|
|
554
|
+
check.__typename === "StatusContext" ||
|
|
555
|
+
(check.status === undefined && check.state !== undefined);
|
|
556
|
+
if (isStatus) {
|
|
557
|
+
if (check.state === "SUCCESS") counts.passed += 1;
|
|
558
|
+
else if (check.state === "PENDING" || check.state === "EXPECTED") counts.pending += 1;
|
|
559
|
+
else counts.failed += 1;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (check.status !== "COMPLETED") counts.pending += 1;
|
|
563
|
+
else if (PASSING_CONCLUSIONS.has(check.conclusion ?? "")) counts.passed += 1;
|
|
564
|
+
else counts.failed += 1;
|
|
565
|
+
}
|
|
566
|
+
return counts;
|
|
294
567
|
}
|
|
@@ -119,6 +119,7 @@ export class GitlabProvider extends ProviderBase {
|
|
|
119
119
|
...(options.fetchImpl === undefined
|
|
120
120
|
? {}
|
|
121
121
|
: { fetchImpl: options.fetchImpl as FetchLike }),
|
|
122
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
122
123
|
label: "repo index",
|
|
123
124
|
});
|
|
124
125
|
|