@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.
Files changed (39) hide show
  1. package/docs/markdown/commands.md +63 -16
  2. package/docs/markdown/repositories-authoring.md +52 -11
  3. package/docs/markdown/repositories-consuming.md +32 -1
  4. package/docs/markdown/repositories-file-formats.md +20 -0
  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/namespace/list.ts +42 -21
  9. package/src/commands/namespace/show.ts +32 -12
  10. package/src/commands/recipe/list.ts +40 -23
  11. package/src/commands/recipe/show.ts +28 -6
  12. package/src/commands/repo/list.ts +67 -10
  13. package/src/commands/repo/release.ts +41 -0
  14. package/src/commands/repo/search.ts +68 -15
  15. package/src/commands/repo/submit.ts +245 -35
  16. package/src/commands/subscription/list.ts +98 -19
  17. package/src/lib/build-preparation.ts +44 -1
  18. package/src/lib/repos/catalog-display.ts +101 -2
  19. package/src/lib/repos/catalog-inputs.ts +145 -17
  20. package/src/lib/repos/catalog.ts +92 -5
  21. package/src/lib/repos/formats/common.ts +20 -0
  22. package/src/lib/repos/formats/recipe-manifest.ts +7 -0
  23. package/src/lib/repos/formats/repo-manifest.ts +8 -0
  24. package/src/lib/repos/freshness.ts +56 -0
  25. package/src/lib/repos/providers/base.ts +33 -1
  26. package/src/lib/repos/providers/github.ts +276 -3
  27. package/src/lib/repos/providers/gitlab.ts +1 -0
  28. package/src/lib/repos/providers/http.ts +7 -2
  29. package/src/lib/repos/providers/index-cache.ts +56 -17
  30. package/src/lib/repos/providers/provider.ts +121 -3
  31. package/src/lib/repos/release/changelog.ts +448 -0
  32. package/src/lib/repos/release/git-state.ts +101 -15
  33. package/src/lib/repos/release/index.ts +2 -0
  34. package/src/lib/repos/release/submissions.ts +214 -0
  35. package/src/lib/repos/release/submit-checkout.ts +271 -0
  36. package/src/lib/repos/release/submit-questions.ts +153 -0
  37. package/src/lib/repos/release/submit-service.ts +581 -174
  38. package/src/lib/repos/subscription-service.ts +138 -9
  39. package/src/utils/flags.ts +24 -0
@@ -11,7 +11,7 @@ import { ConfigError } from "../../errors.js";
11
11
  /** The shape of `fetch` this module needs; the global satisfies it. */
12
12
  export type FetchLike = (
13
13
  url: string,
14
- init?: { headers?: Record<string, string> }
14
+ init?: { headers?: Record<string, string>; signal?: AbortSignal }
15
15
  ) => Promise<{
16
16
  ok: boolean;
17
17
  status: number;
@@ -36,6 +36,8 @@ export type FetchTextOptions = {
36
36
  fetchImpl?: FetchLike;
37
37
  /** What the URL is, named in error messages (for example "repo index"). */
38
38
  label?: string;
39
+ /** Cancels the request, for a caller that will not wait past a deadline. */
40
+ signal?: AbortSignal;
39
41
  };
40
42
 
41
43
  /**
@@ -67,7 +69,10 @@ export async function fetchText(
67
69
 
68
70
  let response: Awaited<ReturnType<FetchLike>>;
69
71
  try {
70
- response = await fetchImpl(url, { headers });
72
+ response = await fetchImpl(
73
+ url,
74
+ options.signal === undefined ? { headers } : { headers, signal: options.signal }
75
+ );
71
76
  } catch (error) {
72
77
  throw new ConfigError(
73
78
  `Sous could not reach ${url} while fetching the ${label}.\n` +
@@ -97,8 +97,13 @@ export type GetIndexOptions = {
97
97
  * shown; the identity is used when nothing supplies one.
98
98
  */
99
99
  label?: string;
100
+ /** Cancels the request, for a caller that will not wait past a deadline. */
101
+ signal?: AbortSignal;
100
102
  };
101
103
 
104
+ /** Which repository to read from upstream, without touching the cache. */
105
+ export type FetchUpstreamOptions = Pick<GetIndexOptions, "url" | "provider" | "signal">;
106
+
102
107
  /** How the cache is built. */
103
108
  export type IndexCacheOptions = {
104
109
  /** The store's root directory; the cache lives in a subdirectory of it. */
@@ -334,23 +339,7 @@ export class IndexCache {
334
339
  * @param options - The repository URL and its provider.
335
340
  */
336
341
  async refresh(identity: string, options: GetIndexOptions): Promise<IndexLookup> {
337
- const provider = this.resolveProvider(options.url, options.provider);
338
- const repo = provider.canonicalize(options.url);
339
- const fetched = await provider.fetchIndex(repo, this.providerOptions);
340
-
341
- let parsed: unknown;
342
- try {
343
- parsed = JSON.parse(fetched.text);
344
- } catch (error) {
345
- throw new ConfigError(
346
- `The index that ${options.url} published is not valid JSON.\n` +
347
- ` ${(error as Error).message}\n` +
348
- ` A repository's index is written by 'sous repo release'; this one may be ` +
349
- `damaged or may not be a sous repository at all.`
350
- );
351
- }
352
-
353
- const index = parseIndexFile(parsed, `${options.url} (${fetched.ref})`);
342
+ const { index, fetched } = await this.fetchFromProvider(options);
354
343
  const timestamp = this.now().toISOString();
355
344
  const meta: IndexMeta = {
356
345
  fetchedAt: timestamp,
@@ -369,6 +358,56 @@ export class IndexCache {
369
358
  return { index: this.applyOverlay(identity, index), source: "network", meta };
370
359
  }
371
360
 
361
+ /**
362
+ * Reads a repository's index from upstream and returns it, WITHOUT writing
363
+ * anything: neither the cached copy nor its sidecar changes. This is what a
364
+ * browsing command reading upstream uses, because only a command that
365
+ * resolves versions should change what the cache holds. Any installed overlay
366
+ * is folded into the answer, as it is for a cached copy. A failure is raised;
367
+ * the caller decides whether a cached copy stands in for it.
368
+ *
369
+ * @param identity - The repository's canonical identity, for the overlay.
370
+ * @param options - The repository URL, its provider, and an optional cancel signal.
371
+ */
372
+ async fetchUpstream(identity: string, options: FetchUpstreamOptions): Promise<IndexFile> {
373
+ const { index } = await this.fetchFromProvider(options);
374
+ return this.applyOverlay(identity, index);
375
+ }
376
+
377
+ /**
378
+ * Fetches and validates a repository's index through its provider. Writes
379
+ * nothing; `refresh` and `fetchUpstream` decide what happens next.
380
+ *
381
+ * @param options - The repository URL, its provider, and an optional cancel signal.
382
+ */
383
+ private async fetchFromProvider(
384
+ options: FetchUpstreamOptions
385
+ ): Promise<{ index: IndexFile; fetched: { ref: string; etag?: string } }> {
386
+ const provider = this.resolveProvider(options.url, options.provider);
387
+ const repo = provider.canonicalize(options.url);
388
+ const fetched = await provider.fetchIndex(
389
+ repo,
390
+ options.signal === undefined
391
+ ? this.providerOptions
392
+ : { ...this.providerOptions, signal: options.signal }
393
+ );
394
+
395
+ let parsed: unknown;
396
+ try {
397
+ parsed = JSON.parse(fetched.text);
398
+ } catch (error) {
399
+ throw new ConfigError(
400
+ `The index that ${options.url} published is not valid JSON.\n` +
401
+ ` ${(error as Error).message}\n` +
402
+ ` A repository's index is written by 'sous repo release'; this one may be ` +
403
+ `damaged or may not be a sous repository at all.`
404
+ );
405
+ }
406
+
407
+ const index = parseIndexFile(parsed, `${options.url} (${fetched.ref})`);
408
+ return { index, fetched };
409
+ }
410
+
372
411
  /**
373
412
  * Forgets a repository's cached index and sidecar, which is what removing a
374
413
  * repository from a project does.
@@ -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
@@ -67,6 +70,8 @@ export type ProviderOptions = {
67
70
  fetchImpl?: FetchLike;
68
71
  /** How subprocesses are run. Defaults to spawning a real process. */
69
72
  run?: CommandRunner;
73
+ /** Cancels an index request, for a caller that will not wait past a deadline. */
74
+ signal?: AbortSignal;
70
75
  };
71
76
 
72
77
  /** What an index fetch returns. */
@@ -140,6 +145,70 @@ export type ProposedChange = {
140
145
  detail: string;
141
146
  };
142
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
+
143
212
  /** One repository host sous knows how to read from. */
144
213
  export interface RepoProvider {
145
214
  /** The provider's stable identifier, as written in a repo config entry. */
@@ -194,6 +263,55 @@ export interface RepoProvider {
194
263
  proposal: ChangeProposal,
195
264
  options?: ProviderOptions
196
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
+ );
197
315
  }
198
316
 
199
317
  /**