@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
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Which recipes take proposed changes, and which paths a change touches.
3
+ *
4
+ * A repository says which of its recipes do not take proposals with a
5
+ * `submissions` block, on its repo manifest (covering every recipe) or on a
6
+ * recipe manifest (covering that recipe, and winning over the repository's).
7
+ * The motivating case is a recipe whose files are copied in from somewhere
8
+ * else: a merged edit to the copy is overwritten by the next copy, and a
9
+ * version it tagged can collide with the one the real source publishes.
10
+ *
11
+ * Two commands read it, with different strengths. `sous repo submit` warns,
12
+ * prints where to go instead, and proposes anyway if the contributor carries
13
+ * on: informed consent, not prevention. `sous repo release --check` fails a
14
+ * pull request that changes such a recipe, because a pull request can be opened
15
+ * without `submit`, and the check is the one gate every change passes.
16
+ *
17
+ * Nothing here decides a comparison base; the callers hand in the paths a
18
+ * change touched, as git reported them.
19
+ */
20
+
21
+ import path from "node:path";
22
+ import semver from "semver";
23
+ import { runGit, type RunOptions } from "../providers/git.js";
24
+ import { defaultBranch, forkPoint, pathsChangedSince } from "./git-state.js";
25
+ import { listRecipeTags, recipeTagKey } from "./tags.js";
26
+ import type { RepoValidation, ValidatedRecipe } from "./validate.js";
27
+
28
+ /** Whether one recipe takes proposals, and which manifest said so. */
29
+ export type SubmissionPolicy = {
30
+ /** True when proposed changes to the recipe are accepted. */
31
+ allowed: boolean;
32
+ /** Where to send a change instead, when the manifest says. */
33
+ instead?: string;
34
+ /** Which manifest decided: the recipe's own, the repository's, or neither. */
35
+ declaredBy: "recipe" | "repository" | "default";
36
+ };
37
+
38
+ /** A recipe a change touches although it does not take proposals. */
39
+ export type RefusingRecipe = {
40
+ /** The recipe key, `namespace/name`. */
41
+ key: string;
42
+ /** The recipe folder, relative to the repository root. */
43
+ path: string;
44
+ /** Where to send the change instead, when the manifest says. */
45
+ instead?: string;
46
+ /** Which manifest said the recipe takes no proposals. */
47
+ declaredBy: "recipe" | "repository";
48
+ /** The changed paths inside the recipe folder, relative to the repository root. */
49
+ changed: string[];
50
+ };
51
+
52
+ /**
53
+ * Whether one recipe takes proposals. A recipe's own block wins over the
54
+ * repository's; with neither, it does.
55
+ *
56
+ * submissionPolicy(validation, recipe);
57
+ * // -> { allowed: false, instead: "Propose it upstream.", declaredBy: "recipe" }
58
+ *
59
+ * @param validation - The validated repository.
60
+ * @param recipe - One of its recipes.
61
+ */
62
+ export function submissionPolicy(
63
+ validation: RepoValidation,
64
+ recipe: ValidatedRecipe
65
+ ): SubmissionPolicy {
66
+ const own = recipe.manifest.submissions;
67
+ if (own !== undefined) {
68
+ return {
69
+ allowed: own.allowed,
70
+ ...(own.instead === undefined ? {} : { instead: own.instead }),
71
+ declaredBy: "recipe",
72
+ };
73
+ }
74
+ const repo = validation.manifest.submissions;
75
+ if (repo !== undefined) {
76
+ return {
77
+ allowed: repo.allowed,
78
+ ...(repo.instead === undefined ? {} : { instead: repo.instead }),
79
+ declaredBy: "repository",
80
+ };
81
+ }
82
+ return { allowed: true, declaredBy: "default" };
83
+ }
84
+
85
+ /**
86
+ * The recipes a change touches that do not take proposals, in the order the
87
+ * repo manifest lists them. A path is inside a recipe when it lies under the
88
+ * recipe folder.
89
+ *
90
+ * recipesRefusingSubmissions(validation, ["recipes/core/x/a.md"]);
91
+ * // -> [{ key: "core/x", path: "recipes/core/x", changed: ["recipes/core/x/a.md"], ... }]
92
+ *
93
+ * @param validation - The validated repository.
94
+ * @param changedPaths - The paths the change touched, relative to the repository root.
95
+ */
96
+ export function recipesRefusingSubmissions(
97
+ validation: RepoValidation,
98
+ changedPaths: ReadonlyArray<string>
99
+ ): RefusingRecipe[] {
100
+ const refusing: RefusingRecipe[] = [];
101
+ for (const recipe of validation.recipes) {
102
+ const policy = submissionPolicy(validation, recipe);
103
+ if (policy.allowed || policy.declaredBy === "default") continue;
104
+
105
+ const changed = pathsInside(recipe.path, changedPaths);
106
+ if (changed.length === 0) continue;
107
+
108
+ refusing.push({
109
+ key: recipe.key,
110
+ path: toPosix(recipe.path),
111
+ ...(policy.instead === undefined ? {} : { instead: policy.instead }),
112
+ declaredBy: policy.declaredBy,
113
+ changed,
114
+ });
115
+ }
116
+ return refusing;
117
+ }
118
+
119
+ /**
120
+ * The paths from a list that lie inside a folder, both relative to the
121
+ * repository root.
122
+ *
123
+ * pathsInside("recipes/core/x", ["recipes/core/x/a.md", "recipes/core/xy/b.md"]);
124
+ * // -> ["recipes/core/x/a.md"]
125
+ *
126
+ * @param folder - The folder, relative to the repository root.
127
+ * @param paths - The paths to test, relative to the repository root.
128
+ */
129
+ export function pathsInside(folder: string, paths: ReadonlyArray<string>): string[] {
130
+ const base = toPosix(path.posix.normalize(toPosix(folder))).replace(/\/+$/, "");
131
+ return paths
132
+ .map(toPosix)
133
+ .filter((entry) => base === "." || entry === base || entry.startsWith(`${base}/`));
134
+ }
135
+
136
+ /** What the pull request check found about recipes that take no proposals. */
137
+ export type SubmissionsCheck = {
138
+ /** The recipes the change touches although they take no proposals. */
139
+ refusing: RefusingRecipe[];
140
+ /**
141
+ * What the change was compared with: the default branch it will merge into,
142
+ * each recipe's last release tag when the checkout holds no copy of that
143
+ * branch, or nothing at all when neither was there to compare with. `none
144
+ * declined` means no recipe declines proposals, so there was nothing to check.
145
+ */
146
+ comparedWith:
147
+ | { kind: "branch"; branch: string }
148
+ | { kind: "tags" }
149
+ | { kind: "nothing" }
150
+ | { kind: "none declined" };
151
+ };
152
+
153
+ /**
154
+ * The check `sous repo release --check` makes for a pull request: which recipes
155
+ * that take no proposals the change touches.
156
+ *
157
+ * The change is what the checked-out commit holds beyond the point it shares
158
+ * with the default branch on `origin`, which is exactly what a pull request
159
+ * proposes. A checkout with no copy of that branch is compared recipe by recipe
160
+ * with each one's last release tag instead: on the default branch a recipe
161
+ * always equals its last tag, so a difference is a change nobody released.
162
+ * A recipe that was never tagged has nothing to compare with and is left out.
163
+ *
164
+ * @param validation - The validated repository.
165
+ * @param options - The command runner to use.
166
+ */
167
+ export async function checkSubmissions(
168
+ validation: RepoValidation,
169
+ options: RunOptions = {}
170
+ ): Promise<SubmissionsCheck> {
171
+ const { rootDir } = validation;
172
+ const guarded = validation.recipes.filter((recipe) => {
173
+ const policy = submissionPolicy(validation, recipe);
174
+ return !policy.allowed && policy.declaredBy !== "default";
175
+ });
176
+ if (guarded.length === 0) return { refusing: [], comparedWith: { kind: "none declined" } };
177
+
178
+ const branch = (await defaultBranch(rootDir, options)) ?? "main";
179
+ const since = await forkPoint(rootDir, "origin", branch, options);
180
+ if (since !== undefined) {
181
+ const changed = await pathsChangedSince(rootDir, since, options);
182
+ return {
183
+ refusing: recipesRefusingSubmissions(validation, changed),
184
+ comparedWith: { kind: "branch", branch },
185
+ };
186
+ }
187
+
188
+ const tags = await listRecipeTags(rootDir, options);
189
+ const changed: string[] = [];
190
+ let comparedAny = false;
191
+ for (const recipe of guarded) {
192
+ const versions = tags
193
+ .filter((tag) => recipeTagKey(tag) === recipe.key && semver.valid(tag.version) !== null)
194
+ .sort((left, right) => semver.rcompare(left.version, right.version));
195
+ const last = versions[0];
196
+ if (last === undefined) continue;
197
+ comparedAny = true;
198
+ const diff = await runGit(
199
+ ["diff", "--name-only", last.tag, "HEAD", "--", toPosix(recipe.path)],
200
+ { cwd: rootDir, run: options.run }
201
+ );
202
+ if (diff.length > 0) changed.push(...diff.split("\n").filter((line) => line.length > 0));
203
+ }
204
+
205
+ return {
206
+ refusing: recipesRefusingSubmissions(validation, changed),
207
+ comparedWith: comparedAny ? { kind: "tags" } : { kind: "nothing" },
208
+ };
209
+ }
210
+
211
+ /** Rewrites a path with forward slashes, which is how git reports them. */
212
+ function toPosix(value: string): string {
213
+ return value.split(path.sep).join("/");
214
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Which working copy `sous repo submit` proposes from.
3
+ *
4
+ * Inside a recipe repository with no argument, it is that repository, as it
5
+ * always was. Inside a project, the argument names a repository the project
6
+ * knows (resolved through `src/lib/refs/`, like every reference), and the
7
+ * submission runs in that repository's checkout:
8
+ *
9
+ * - a linked repository is read from the checkout its link points at;
10
+ * - a repository no longer linked, whose checkout sous cloned is still where
11
+ * `sous repo link` puts one, is submitted from that checkout, with a note
12
+ * saying it is not linked here;
13
+ * - a repository with no checkout at all cannot be submitted from, and the
14
+ * error says there is no working copy to propose from.
15
+ *
16
+ * With no argument inside a project, exactly one linked repository is used and
17
+ * named; several are a question, which a run with no terminal cannot ask.
18
+ *
19
+ * Nothing here writes anything. It reads the links maps and looks at the disk.
20
+ */
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { ConfigError } from "../../errors.js";
25
+ import { nonInteractiveError } from "../../interactive.js";
26
+ import { findRepository, pickReference, type ReferenceRepo } from "../../refs/index.js";
27
+ import type { RepoEntry } from "../../settings.js";
28
+ import { isGitCheckout, remoteUrlOf, repoSlugFromUrl, sameRemote } from "../git-clone.js";
29
+ import { globalReposDir, projectReposDir, readEffectiveLinks } from "../links.js";
30
+ import { askChoice } from "../../../utils/prompts.js";
31
+ import { findRepoRoot } from "./validate.js";
32
+
33
+ /** The project a submission may be run from, when one was found. */
34
+ export type SubmitProject = {
35
+ /** The project's discovered `.sous/` directory. */
36
+ sousDir: string;
37
+ /** The repositories the project uses, keyed by short name. */
38
+ repos: Record<string, RepoEntry>;
39
+ };
40
+
41
+ /** What `findSubmitCheckout` needs to know. */
42
+ export type FindSubmitCheckoutOptions = {
43
+ /** The working directory the command was run from. */
44
+ cwd: string;
45
+ /** The repository the command line named, when it named one. */
46
+ repo?: string;
47
+ /** The project around the working directory, when there is one. */
48
+ project?: SubmitProject;
49
+ /** Whether a question may be asked. */
50
+ interactive: boolean;
51
+ /** The environment, for the machine-wide links map and checkouts. */
52
+ env?: NodeJS.ProcessEnv;
53
+ /** How a choice between several linked repositories is asked. */
54
+ choose?: (message: string, names: string[]) => Promise<string>;
55
+ /** Where the facts about a resolved reference are written. */
56
+ write?: (message: string) => void;
57
+ };
58
+
59
+ /** The working copy a submission runs in, and how it was chosen. */
60
+ export type SubmitCheckout = {
61
+ /** The checkout's root directory. */
62
+ rootDir: string;
63
+ /** The project's short name for the repository, when a project named it. */
64
+ repo?: string;
65
+ /** How the checkout was found, in one sentence, ready to print. */
66
+ reason: string;
67
+ /** Anything else worth saying about it, such as a checkout that is no longer linked. */
68
+ notes: string[];
69
+ };
70
+
71
+ /**
72
+ * Works out which checkout a submission runs in.
73
+ *
74
+ * @param options - The working directory, the argument, the project and the testing seams.
75
+ */
76
+ export async function findSubmitCheckout(
77
+ options: FindSubmitCheckoutOptions
78
+ ): Promise<SubmitCheckout> {
79
+ const { cwd, project } = options;
80
+ const env = options.env ?? process.env;
81
+
82
+ if (options.repo === undefined) {
83
+ const inside = recipeRepoAround(cwd);
84
+ if (inside !== undefined) {
85
+ return { rootDir: inside, reason: "The working directory is inside it.", notes: [] };
86
+ }
87
+ if (project === undefined) {
88
+ // Neither a recipe repository nor a project: the recipe repository error
89
+ // is the one that says what to do.
90
+ findRepoRoot(cwd);
91
+ }
92
+ return chooseLinked(project!, env, options);
93
+ }
94
+
95
+ if (project === undefined) {
96
+ throw new ConfigError(
97
+ `'${options.repo}' names a repository a project uses, but ${path.resolve(cwd)} is not ` +
98
+ `inside a sous project.\n` +
99
+ ` Run the command from inside the project that links the repository, or from inside ` +
100
+ `the repository's own checkout with no argument.`
101
+ );
102
+ }
103
+
104
+ const links = readEffectiveLinks(project.sousDir, env);
105
+ const reference = referenceRepos(project, links);
106
+ const match = await pickReference(findRepository(options.repo, { repos: reference }), {
107
+ search: options.repo,
108
+ interactive: options.interactive,
109
+ ...(options.write === undefined ? {} : { write: options.write }),
110
+ details: [
111
+ reference.length === 0
112
+ ? " This project uses no repositories."
113
+ : ` This project uses: ${reference.map((entry) => entry.name).join(", ")}.`,
114
+ ],
115
+ });
116
+ const name = match.repo ?? match.key;
117
+
118
+ const link = links[name];
119
+ if (link !== undefined) {
120
+ if (!fs.existsSync(link.path)) {
121
+ throw noWorkingCopy(name, `Its link points at ${link.path}, which does not exist.`);
122
+ }
123
+ return {
124
+ rootDir: link.path,
125
+ repo: name,
126
+ reason: `'${name}' is linked to this checkout.`,
127
+ notes: [],
128
+ };
129
+ }
130
+
131
+ const leftover = leftoverCheckout(name, project, env);
132
+ if (leftover !== undefined) {
133
+ return {
134
+ rootDir: leftover,
135
+ repo: name,
136
+ reason: `Sous cloned '${name}' into this checkout earlier.`,
137
+ notes: [
138
+ `'${name}' is not linked in this project, so builds read its published versions; ` +
139
+ `the checkout sous cloned for it is still at ${leftover}, and the change is ` +
140
+ `proposed from there.`,
141
+ ],
142
+ };
143
+ }
144
+
145
+ throw noWorkingCopy(
146
+ name,
147
+ `It is not linked in this project, and no checkout of it is where 'sous repo link' ` +
148
+ `clones one.`
149
+ );
150
+ }
151
+
152
+ /** The recipe repository around a directory, or undefined when there is none. */
153
+ function recipeRepoAround(cwd: string): string | undefined {
154
+ try {
155
+ return findRepoRoot(cwd);
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * With no argument inside a project: the one linked repository, or a choice
163
+ * between several.
164
+ */
165
+ async function chooseLinked(
166
+ project: SubmitProject,
167
+ env: NodeJS.ProcessEnv,
168
+ options: FindSubmitCheckoutOptions
169
+ ): Promise<SubmitCheckout> {
170
+ const links = readEffectiveLinks(project.sousDir, env);
171
+ const names = Object.keys(links).sort();
172
+
173
+ if (names.length === 0) {
174
+ throw new ConfigError(
175
+ "This project links no repository, so there is no working copy to propose from.\n" +
176
+ " Link one with 'sous repo link <repo>', make your change in its checkout, then run " +
177
+ "'sous repo submit <repo>'."
178
+ );
179
+ }
180
+
181
+ let name: string;
182
+ let reason: string;
183
+ if (names.length === 1) {
184
+ name = names[0]!;
185
+ reason = `It is the only repository this project links.`;
186
+ } else {
187
+ if (!options.interactive) {
188
+ throw nonInteractiveError({
189
+ prompt: "which linked repository to propose a change from",
190
+ remedy: `name it as the argument, as in 'sous repo submit ${names[0]}'.`,
191
+ details: [`This project links ${names.join(", ")}.`],
192
+ });
193
+ }
194
+ const choose =
195
+ options.choose ??
196
+ ((message: string, offered: string[]) =>
197
+ askChoice(
198
+ message,
199
+ offered.map((entry) => ({ name: `${entry} ${links[entry]!.path}`, value: entry }))
200
+ ));
201
+ name = await choose("Which linked repository should the change be proposed from?", names);
202
+ reason = "You chose it.";
203
+ }
204
+
205
+ const link = links[name]!;
206
+ if (!fs.existsSync(link.path)) {
207
+ throw noWorkingCopy(name, `Its link points at ${link.path}, which does not exist.`);
208
+ }
209
+ return { rootDir: link.path, repo: name, reason, notes: [] };
210
+ }
211
+
212
+ /**
213
+ * The repositories a reference may name here: every one the project uses, and
214
+ * any linked name the config does not list, so a link is never unreachable.
215
+ */
216
+ function referenceRepos(
217
+ project: SubmitProject,
218
+ links: Record<string, { path: string }>
219
+ ): ReferenceRepo[] {
220
+ const repos: ReferenceRepo[] = Object.entries(project.repos).map(([name, entry]) => ({
221
+ name,
222
+ url: entry.url,
223
+ namespaces: [],
224
+ recipes: [],
225
+ }));
226
+ for (const name of Object.keys(links).sort()) {
227
+ if (project.repos[name] === undefined) {
228
+ repos.push({ name, url: links[name]!.path, namespaces: [], recipes: [] });
229
+ }
230
+ }
231
+ return repos;
232
+ }
233
+
234
+ /**
235
+ * A checkout sous cloned for a repository that is no longer linked: the
236
+ * project's own clone first, then the machine-wide one, each only when it is a
237
+ * checkout of that same repository.
238
+ */
239
+ function leftoverCheckout(
240
+ name: string,
241
+ project: SubmitProject,
242
+ env: NodeJS.ProcessEnv
243
+ ): string | undefined {
244
+ const url = project.repos[name]?.url;
245
+ if (url === undefined) return undefined;
246
+
247
+ let slug: { owner: string; name: string };
248
+ try {
249
+ slug = repoSlugFromUrl(url);
250
+ } catch {
251
+ return undefined;
252
+ }
253
+
254
+ for (const base of [projectReposDir(project.sousDir), globalReposDir(env)]) {
255
+ const directory = path.join(base, slug.owner, slug.name);
256
+ if (!isGitCheckout(directory)) continue;
257
+ const remote = remoteUrlOf(directory);
258
+ if (remote !== undefined && sameRemote(remote, url)) return directory;
259
+ }
260
+ return undefined;
261
+ }
262
+
263
+ /** The error for a repository with no working copy to propose from. */
264
+ function noWorkingCopy(name: string, why: string): ConfigError {
265
+ return new ConfigError(
266
+ `There is no working copy of '${name}' to propose a change from.\n` +
267
+ ` ${why}\n` +
268
+ ` Link it with 'sous repo link ${name}', make your change in its checkout, then run ` +
269
+ `the command again.`
270
+ );
271
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * How `sous repo submit` asks its questions.
3
+ *
4
+ * The sequencer (`submit-service.ts`) never prompts; it calls a
5
+ * `SubmitQuestions` and carries on with the answer. This module builds that
6
+ * object from the two facts a command knows: whether a question may be asked
7
+ * at all (`src/lib/interactive.ts` decides), and whether the shared
8
+ * confirmation flag already answered every yes-or-no question.
9
+ *
10
+ * A question nobody can ask raises the error that names the flag answering it,
11
+ * so a script learns what to pass rather than hanging on a prompt it cannot see:
12
+ * the title and description name `--title` and `--body` together, and every
13
+ * confirmation names `--yes`.
14
+ */
15
+
16
+ import { editor } from "@inquirer/prompts";
17
+ import { nonInteractiveError } from "../../interactive.js";
18
+ import { blankLines, keysHelpTip, log } from "../../../utils/formatting.js";
19
+ import { askChoice, askYesNo } from "../../../utils/prompts.js";
20
+ import { valuePrompt } from "../../../utils/value-prompt.js";
21
+ import type { ProposalSummary } from "../providers/provider.js";
22
+ import type { ChangedPath } from "./git-state.js";
23
+ import type { NextBranchChoice, SubmitQuestions } from "./submit-service.js";
24
+
25
+ /** The prompts the questions are asked with; each one is replaceable in a test. */
26
+ export type SubmitPrompts = {
27
+ /** Asks for one line of text, with an editor behind Tab for a longer answer. */
28
+ text(message: string): Promise<string>;
29
+ /** Asks a yes-or-no question. */
30
+ confirm(message: string): Promise<boolean>;
31
+ /** Asks the contributor to pick one of several choices. */
32
+ choose<T>(message: string, choices: Array<{ name: string; value: T }>): Promise<T>;
33
+ };
34
+
35
+ /** What `submitQuestions` needs to know. */
36
+ export type SubmitQuestionOptions = {
37
+ /** Whether a question may be asked. */
38
+ interactive: boolean;
39
+ /** True when the confirmation flag answered every yes-or-no question already. */
40
+ yes: boolean;
41
+ /** How the questions are put. Defaults to the shared terminal prompts. */
42
+ prompts?: SubmitPrompts;
43
+ /** Where a list shown before a question is written. Defaults to the console. */
44
+ write?: (line: string) => void;
45
+ };
46
+
47
+ /** The flag spellings every confirmation's remedy names. */
48
+ const YES_REMEDY = "pass '--yes' (spelled '-y' or '--force' if you prefer)";
49
+
50
+ /** The remedy for a missing title or description, naming both flags. */
51
+ const TEXT_REMEDY =
52
+ "pass '--title' and '--body'. A new proposal, and a commit sous makes for you, both need " +
53
+ "a title and a description written by you.";
54
+
55
+ /**
56
+ * Builds the questions a submission asks, bound to this run's terminal and
57
+ * flags.
58
+ *
59
+ * @param options - Whether asking is possible, whether `--yes` was passed, and the prompts.
60
+ */
61
+ export function submitQuestions(options: SubmitQuestionOptions): SubmitQuestions {
62
+ const prompts = options.prompts ?? terminalPrompts;
63
+ const write = options.write ?? log;
64
+ const { interactive, yes } = options;
65
+
66
+ const askText = async (message: string): Promise<string> => {
67
+ if (!interactive) {
68
+ throw nonInteractiveError({
69
+ prompt: "for the proposal's title and description",
70
+ remedy: TEXT_REMEDY,
71
+ });
72
+ }
73
+ return prompts.text(message);
74
+ };
75
+
76
+ return {
77
+ title: () => askText("What is the title of this proposal?"),
78
+ body: () => askText("Describe the change: what it does, and why."),
79
+
80
+ async confirmCommit(paths: ReadonlyArray<ChangedPath>): Promise<boolean> {
81
+ if (yes) return true;
82
+ write(" These paths are uncommitted, and '--commit' would commit all of them:");
83
+ for (const entry of paths) write(` ${entry.path}`);
84
+ if (!interactive) {
85
+ throw nonInteractiveError({
86
+ prompt: "whether to commit the paths listed above",
87
+ remedy: `${YES_REMEDY} to commit them without being asked.`,
88
+ });
89
+ }
90
+ return prompts.confirm("Commit these paths?");
91
+ },
92
+
93
+ async proceedDespiteSubmissions(): Promise<boolean> {
94
+ if (yes) return true;
95
+ if (!interactive) {
96
+ throw nonInteractiveError({
97
+ prompt: "whether to propose a change to recipes that do not take proposals",
98
+ remedy: `${YES_REMEDY} to propose it anyway.`,
99
+ });
100
+ }
101
+ return prompts.confirm("Propose the change anyway?");
102
+ },
103
+
104
+ async nextBranch(merged: ProposalSummary, generated: string): Promise<NextBranchChoice> {
105
+ if (yes) return { kind: "generate" };
106
+ if (!interactive) {
107
+ throw nonInteractiveError({
108
+ prompt: "which new branch to continue on, now the proposal was merged",
109
+ remedy: `${YES_REMEDY} to continue on a branch sous names for you.`,
110
+ details: [`The merged proposal was '${merged.title}'.`],
111
+ });
112
+ }
113
+ const picked = await prompts.choose<"name" | "generate" | "cancel">(
114
+ "Continue on a new branch?",
115
+ [
116
+ { name: `Generate one: ${generated}`, value: "generate" },
117
+ { name: "Name the branch myself", value: "name" },
118
+ { name: "Cancel", value: "cancel" },
119
+ ]
120
+ );
121
+ if (picked !== "name") return { kind: picked };
122
+ const name = (await prompts.text("What should the new branch be called?")).trim();
123
+ return name.length === 0 ? { kind: "generate" } : { kind: "name", name };
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * The prompts a real terminal gets. A text question answers on Enter; Tab
130
+ * opens the contributor's editor instead, which is where a description longer
131
+ * than one line is written.
132
+ */
133
+ const terminalPrompts: SubmitPrompts = {
134
+ async text(message: string): Promise<string> {
135
+ blankLines(2);
136
+ for (;;) {
137
+ const answered = await valuePrompt({
138
+ message,
139
+ hint: keysHelpTip([
140
+ ["⏎", "answer"],
141
+ ["⇥", "advanced"],
142
+ ]),
143
+ validate: (value) => (value.trim().length > 0 ? true : "An answer is required."),
144
+ });
145
+ if (answered.kind === "value") return answered.value;
146
+ const written = await editor({ message, waitForUserInput: false });
147
+ if (written.trim().length > 0) return written;
148
+ }
149
+ },
150
+ confirm: (message: string) => askYesNo(message),
151
+ choose: <T>(message: string, choices: Array<{ name: string; value: T }>) =>
152
+ askChoice(message, choices),
153
+ };