@sous-io/sous 0.2.16 → 0.2.18
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 +59 -13
- package/docs/markdown/repositories-authoring.md +75 -13
- package/docs/markdown/repositories-consuming.md +44 -2
- package/docs/markdown/repositories-file-formats.md +22 -1
- 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/repo/release.ts +41 -0
- package/src/commands/repo/submit.ts +245 -35
- package/src/commands/repo/unlink.ts +333 -20
- package/src/commands/subscription/update.ts +215 -0
- package/src/lib/repos/formats/common.ts +20 -0
- package/src/lib/repos/formats/links-map.ts +5 -3
- package/src/lib/repos/formats/recipe-manifest.ts +7 -0
- package/src/lib/repos/formats/repo-manifest.ts +8 -0
- package/src/lib/repos/git-clone.ts +71 -0
- package/src/lib/repos/links.ts +2 -1
- package/src/lib/repos/locked-recipes.ts +22 -0
- package/src/lib/repos/providers/base.ts +33 -1
- package/src/lib/repos/providers/github.ts +275 -3
- package/src/lib/repos/providers/provider.ts +119 -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/resolver.ts +25 -2
- package/src/lib/repos/seed.ts +64 -5
- package/src/lib/repos/store/hash.ts +68 -8
- package/src/lib/repos/subscription-service.ts +744 -20
- package/src/lib/repos/update-plan.ts +234 -0
|
@@ -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
|
/**
|
|
@@ -227,6 +227,77 @@ export function cloneRepo(
|
|
|
227
227
|
return { depth: 0, fellBackToFullClone: depth > 0 };
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/** Work in a checkout that exists nowhere else, and would be lost with it. */
|
|
231
|
+
export type UnsavedWork = {
|
|
232
|
+
/** Every changed or untracked path, as `git status --porcelain` prints it. */
|
|
233
|
+
uncommitted: string[];
|
|
234
|
+
/** Every commit on a local branch that no remote has, one line each. */
|
|
235
|
+
unpushed: string[];
|
|
236
|
+
/** Every stash entry, one line each. */
|
|
237
|
+
stashes: string[];
|
|
238
|
+
/**
|
|
239
|
+
* Why git could not be asked, when it could not. Nothing is known about the
|
|
240
|
+
* checkout then, which a caller must treat as possibly holding work.
|
|
241
|
+
*/
|
|
242
|
+
unknown?: string;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* What in a checkout would be lost if it were deleted: uncommitted changes,
|
|
247
|
+
* commits no remote has, and stashes.
|
|
248
|
+
*
|
|
249
|
+
* unsavedWork("/path/to/checkout")
|
|
250
|
+
* // -> { uncommitted: ["M README.md"], unpushed: [], stashes: [] }
|
|
251
|
+
*
|
|
252
|
+
* @param directory - The checkout to inspect.
|
|
253
|
+
* @param options - The git runner to use.
|
|
254
|
+
*/
|
|
255
|
+
export function unsavedWork(directory: string, options: GitOptions = {}): UnsavedWork {
|
|
256
|
+
const runner = options.runner ?? runGit;
|
|
257
|
+
const lines = (text: string): string[] =>
|
|
258
|
+
text
|
|
259
|
+
.split("\n")
|
|
260
|
+
.map((line) => line.trim())
|
|
261
|
+
.filter((line) => line.length > 0);
|
|
262
|
+
|
|
263
|
+
const status = runner(["status", "--porcelain"], { cwd: directory });
|
|
264
|
+
if (status.status !== 0) {
|
|
265
|
+
return {
|
|
266
|
+
uncommitted: [],
|
|
267
|
+
unpushed: [],
|
|
268
|
+
stashes: [],
|
|
269
|
+
unknown:
|
|
270
|
+
`git could not read the checkout at ${directory}` +
|
|
271
|
+
(status.stderr.length > 0 ? `: ${status.stderr}` : "."),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const unpushed = runner(["log", "--branches", "--not", "--remotes", "--oneline"], {
|
|
276
|
+
cwd: directory,
|
|
277
|
+
});
|
|
278
|
+
const stashes = runner(["stash", "list"], { cwd: directory });
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
uncommitted: lines(status.stdout),
|
|
282
|
+
unpushed: unpushed.status === 0 ? lines(unpushed.stdout) : [],
|
|
283
|
+
stashes: stashes.status === 0 ? lines(stashes.stdout) : [],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* True when an inspection found nothing that would be lost.
|
|
289
|
+
*
|
|
290
|
+
* @param work - What `unsavedWork` found.
|
|
291
|
+
*/
|
|
292
|
+
export function hasNoUnsavedWork(work: UnsavedWork): boolean {
|
|
293
|
+
return (
|
|
294
|
+
work.unknown === undefined &&
|
|
295
|
+
work.uncommitted.length === 0 &&
|
|
296
|
+
work.unpushed.length === 0 &&
|
|
297
|
+
work.stashes.length === 0
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
230
301
|
/**
|
|
231
302
|
* True when two remote URLs name the same repository, ignoring the differences
|
|
232
303
|
* that never change what is fetched: a `.git` suffix, a trailing slash, the
|
package/src/lib/repos/links.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* A link redirects one repository's resolution away from the store and at a
|
|
5
5
|
* real working copy on disk, which is how a maintainer edits recipes: edits
|
|
6
6
|
* happen in a checkout, never in the store. `sous repo link` writes an entry
|
|
7
|
-
* here; `sous repo unlink` removes it and leaves the checkout alone
|
|
7
|
+
* here; `sous repo unlink` removes it and leaves the checkout alone unless
|
|
8
|
+
* `--remove` asks it to delete one sous cloned.
|
|
8
9
|
*
|
|
9
10
|
* Two maps exist. The project's `.sous/sous.links.json` covers one project; the
|
|
10
11
|
* machine-wide `$SOUS_HOME/sous.links.json` covers every project on the machine,
|
|
@@ -252,3 +252,25 @@ export function projectSubscriptionRefs(
|
|
|
252
252
|
}
|
|
253
253
|
return [...refs].sort();
|
|
254
254
|
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Every locked recipe reachable from a set of keys through the lockfile's
|
|
258
|
+
* holder lists: the keys themselves, whatever they hold, and so on.
|
|
259
|
+
*
|
|
260
|
+
* @param lock - The lockfile.
|
|
261
|
+
* @param roots - The keys to start from.
|
|
262
|
+
*/
|
|
263
|
+
export function lockedClosure(lock: Lockfile, roots: string[]): string[] {
|
|
264
|
+
const reached = new Set(roots.filter((key) => Object.hasOwn(lock.recipes, key)));
|
|
265
|
+
const queue = [...reached];
|
|
266
|
+
while (queue.length > 0) {
|
|
267
|
+
const holder = queue.shift()!;
|
|
268
|
+
for (const [key, entry] of Object.entries(lock.recipes)) {
|
|
269
|
+
if (reached.has(key) || !entry.requestedBy.includes(holder)) continue;
|
|
270
|
+
reached.add(key);
|
|
271
|
+
queue.push(key);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return [...reached].sort();
|
|
275
|
+
}
|
|
276
|
+
|
|
@@ -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 = {
|
|
@@ -292,4 +301,267 @@ export class GithubProvider extends ProviderBase {
|
|
|
292
301
|
}
|
|
293
302
|
return { url, detail: `The ${this.proposalNoun} is at ${url}.` };
|
|
294
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;
|
|
295
567
|
}
|
|
@@ -27,10 +27,13 @@ import type { FetchLike } from "./http.js";
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* What a provider can do. `fetch` is the read path every provider implements;
|
|
30
|
-
* `submit` is the propose-a-change path
|
|
31
|
-
*
|
|
30
|
+
* `submit` is the propose-a-change path; `proposals` is looking a proposal up
|
|
31
|
+
* again afterwards (finding the one a branch already has, reporting its status,
|
|
32
|
+
* and replacing its title or body), which is what lets `sous repo submit`
|
|
33
|
+
* handle a proposal's whole life rather than only its first day. A provider
|
|
34
|
+
* declares a feature only once it genuinely supports it.
|
|
32
35
|
*/
|
|
33
|
-
export type ProviderFeature = "fetch" | "submit";
|
|
36
|
+
export type ProviderFeature = "fetch" | "submit" | "proposals";
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* The identifier a repo entry uses to name its provider explicitly. `local` is a
|
|
@@ -142,6 +145,70 @@ export type ProposedChange = {
|
|
|
142
145
|
detail: string;
|
|
143
146
|
};
|
|
144
147
|
|
|
148
|
+
/** Where a proposal stands: still open, merged, or closed without merging. */
|
|
149
|
+
export type ProposalState = "open" | "merged" | "closed";
|
|
150
|
+
|
|
151
|
+
/** One proposal, as plain data every provider can describe. */
|
|
152
|
+
export type ProposalSummary = {
|
|
153
|
+
/** How the provider identifies it, such as a pull request number. */
|
|
154
|
+
id: string;
|
|
155
|
+
/** Its address, when the provider reported one. */
|
|
156
|
+
url?: string;
|
|
157
|
+
/** Where it stands. */
|
|
158
|
+
state: ProposalState;
|
|
159
|
+
/** Its current title. */
|
|
160
|
+
title: string;
|
|
161
|
+
/** True when it is a draft. */
|
|
162
|
+
draft: boolean;
|
|
163
|
+
/** The branch it targets, when the provider reported it. */
|
|
164
|
+
base?: string;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Which proposal to look for: the one a branch was pushed for. */
|
|
168
|
+
export type ProposalQuery = {
|
|
169
|
+
/** The branch the change is on. */
|
|
170
|
+
branch: string;
|
|
171
|
+
/**
|
|
172
|
+
* True when the branch lives on a fork rather than in the repository itself.
|
|
173
|
+
* A proposal from a fork is found by the fork's owner as well as the branch,
|
|
174
|
+
* so two contributors' branches of the same name are never confused.
|
|
175
|
+
*/
|
|
176
|
+
fromFork: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* The account the fork lives under, when the caller knows it. Left out, the
|
|
179
|
+
* provider asks the host which account is signed in.
|
|
180
|
+
*/
|
|
181
|
+
forkOwner?: string;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** How a proposal's review is going, in words every host can be mapped onto. */
|
|
185
|
+
export type ProposalReview = "approved" | "changes requested" | "review required";
|
|
186
|
+
|
|
187
|
+
/** How the automated checks on a proposal stand, counted. */
|
|
188
|
+
export type ProposalChecks = {
|
|
189
|
+
passed: number;
|
|
190
|
+
failed: number;
|
|
191
|
+
pending: number;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** Everything a status report says about one proposal. */
|
|
195
|
+
export type ProposalStatus = {
|
|
196
|
+
/** The proposal itself. */
|
|
197
|
+
proposal: ProposalSummary;
|
|
198
|
+
/** How its review is going, when the host reports it. */
|
|
199
|
+
review?: ProposalReview;
|
|
200
|
+
/** How its checks stand, when it has any. */
|
|
201
|
+
checks?: ProposalChecks;
|
|
202
|
+
/** Whether it can be merged as it stands; undefined when the host is still working it out. */
|
|
203
|
+
mergeable?: boolean;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** What to replace on an open proposal. A field left out is left as it is. */
|
|
207
|
+
export type ProposalUpdate = {
|
|
208
|
+
title?: string;
|
|
209
|
+
body?: string;
|
|
210
|
+
};
|
|
211
|
+
|
|
145
212
|
/** One repository host sous knows how to read from. */
|
|
146
213
|
export interface RepoProvider {
|
|
147
214
|
/** The provider's stable identifier, as written in a repo config entry. */
|
|
@@ -196,6 +263,55 @@ export interface RepoProvider {
|
|
|
196
263
|
proposal: ChangeProposal,
|
|
197
264
|
options?: ProviderOptions
|
|
198
265
|
): Promise<ProposedChange>;
|
|
266
|
+
|
|
267
|
+
// --- Proposals after the fact, answered by a provider that declares `proposals`
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The proposal a branch was pushed for, or undefined when it has none. When a
|
|
271
|
+
* branch has had several, the open one wins, and otherwise the newest.
|
|
272
|
+
*/
|
|
273
|
+
findProposal?(
|
|
274
|
+
repo: CanonicalRepo,
|
|
275
|
+
query: ProposalQuery,
|
|
276
|
+
options?: ProviderOptions
|
|
277
|
+
): Promise<ProposalSummary | undefined>;
|
|
278
|
+
/** Where one proposal stands: its state, its review and its checks. */
|
|
279
|
+
proposalStatus?(
|
|
280
|
+
repo: CanonicalRepo,
|
|
281
|
+
id: string,
|
|
282
|
+
options?: ProviderOptions
|
|
283
|
+
): Promise<ProposalStatus>;
|
|
284
|
+
/** Replaces an open proposal's title, its body, or both. */
|
|
285
|
+
updateProposal?(
|
|
286
|
+
repo: CanonicalRepo,
|
|
287
|
+
id: string,
|
|
288
|
+
update: ProposalUpdate,
|
|
289
|
+
options?: ProviderOptions
|
|
290
|
+
): Promise<ProposedChange>;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* A provider that can find a proposal again, report on it and change its text.
|
|
295
|
+
* This is what declaring the `proposals` feature promises.
|
|
296
|
+
*/
|
|
297
|
+
export type ProposalCapableProvider = RepoProvider &
|
|
298
|
+
Required<Pick<RepoProvider, "findProposal" | "proposalStatus" | "updateProposal">>;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* True when a provider declares the `proposals` feature and really does answer
|
|
302
|
+
* all three calls behind it.
|
|
303
|
+
*
|
|
304
|
+
* @param provider - The provider to test.
|
|
305
|
+
*/
|
|
306
|
+
export function supportsProposals(
|
|
307
|
+
provider: RepoProvider
|
|
308
|
+
): provider is ProposalCapableProvider {
|
|
309
|
+
return (
|
|
310
|
+
provider.features.includes("proposals") &&
|
|
311
|
+
typeof provider.findProposal === "function" &&
|
|
312
|
+
typeof provider.proposalStatus === "function" &&
|
|
313
|
+
typeof provider.updateProposal === "function"
|
|
314
|
+
);
|
|
199
315
|
}
|
|
200
316
|
|
|
201
317
|
/**
|