@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.
Files changed (33) hide show
  1. package/docs/markdown/commands.md +59 -13
  2. package/docs/markdown/repositories-authoring.md +75 -13
  3. package/docs/markdown/repositories-consuming.md +44 -2
  4. package/docs/markdown/repositories-file-formats.md +22 -1
  5. package/docs/markdown/repositories-providers.md +20 -10
  6. package/package.json +1 -1
  7. package/recipes/core/sous-skills/sous.recipe.yaml +8 -1
  8. package/src/commands/repo/release.ts +41 -0
  9. package/src/commands/repo/submit.ts +245 -35
  10. package/src/commands/repo/unlink.ts +333 -20
  11. package/src/commands/subscription/update.ts +215 -0
  12. package/src/lib/repos/formats/common.ts +20 -0
  13. package/src/lib/repos/formats/links-map.ts +5 -3
  14. package/src/lib/repos/formats/recipe-manifest.ts +7 -0
  15. package/src/lib/repos/formats/repo-manifest.ts +8 -0
  16. package/src/lib/repos/git-clone.ts +71 -0
  17. package/src/lib/repos/links.ts +2 -1
  18. package/src/lib/repos/locked-recipes.ts +22 -0
  19. package/src/lib/repos/providers/base.ts +33 -1
  20. package/src/lib/repos/providers/github.ts +275 -3
  21. package/src/lib/repos/providers/provider.ts +119 -3
  22. package/src/lib/repos/release/changelog.ts +448 -0
  23. package/src/lib/repos/release/git-state.ts +101 -15
  24. package/src/lib/repos/release/index.ts +2 -0
  25. package/src/lib/repos/release/submissions.ts +214 -0
  26. package/src/lib/repos/release/submit-checkout.ts +271 -0
  27. package/src/lib/repos/release/submit-questions.ts +153 -0
  28. package/src/lib/repos/release/submit-service.ts +581 -174
  29. package/src/lib/repos/resolver.ts +25 -2
  30. package/src/lib/repos/seed.ts +64 -5
  31. package/src/lib/repos/store/hash.ts +68 -8
  32. package/src/lib/repos/subscription-service.ts +744 -20
  33. 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
@@ -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, and proposes a change through
63
- * the GitHub CLI ('gh').
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, which arrives in a later phase. A
31
- * provider declares the feature only once it genuinely supports it.
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
  /**