@sous-io/sous 0.2.16 → 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.
@@ -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
  /**