@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Proposing a change to a recipe repository
|
|
2
|
+
* Proposing a change to a recipe repository, over the proposal's whole life.
|
|
3
3
|
*
|
|
4
4
|
* `submit` universally means "propose a change for maintainers to review". It
|
|
5
5
|
* never publishes and never writes to a repository directly; the fork and
|
|
@@ -7,20 +7,34 @@
|
|
|
7
7
|
* already has the contributor's credentials through that host's command line
|
|
8
8
|
* tool.
|
|
9
9
|
*
|
|
10
|
+
* One command covers a proposal from start to finish, for the branch that is
|
|
11
|
+
* checked out or the one `--branch` names:
|
|
12
|
+
*
|
|
13
|
+
* - no proposal yet: open one;
|
|
14
|
+
* - an open proposal: push whatever is new, which updates it, and replace its
|
|
15
|
+
* title or body when new ones are given; with nothing new, report on it;
|
|
16
|
+
* - a merged proposal: say so, and continue on a new branch (named, generated,
|
|
17
|
+
* or not at all), which gets a new proposal;
|
|
18
|
+
* - a proposal closed without merging: say so, and open a fresh one;
|
|
19
|
+
* - a pushed branch holding commits the local one lacks: git refuses the push,
|
|
20
|
+
* and its refusal is passed through. Nothing here ever forces a push.
|
|
21
|
+
*
|
|
10
22
|
* This module is a SEQUENCER and nothing more. It knows the order the steps go
|
|
11
23
|
* in, what each one is called, and what to say when one fails; it does not know
|
|
12
24
|
* that GitHub exists, which tool proposes a change, or how a fork is spelled.
|
|
13
25
|
* Every host-specific fact is asked of the provider interface and comes back as
|
|
14
|
-
* plain data, which is what keeps a third provider a single new file.
|
|
26
|
+
* plain data, which is what keeps a third provider a single new file. Every
|
|
27
|
+
* question is asked through `SubmitQuestions`, so the command decides how a
|
|
28
|
+
* question is put (or which flag answered it) and this module never prompts.
|
|
15
29
|
*
|
|
16
|
-
*
|
|
30
|
+
* Three rules shape everything here:
|
|
17
31
|
*
|
|
32
|
+
* - Everything is worked out, and every question asked, before anything is
|
|
33
|
+
* written. A contributor who declines, or a run that cannot ask, leaves the
|
|
34
|
+
* checkout exactly as it found it.
|
|
18
35
|
* - Nothing is sent until the repository validates and the contributor has left
|
|
19
|
-
* its index alone.
|
|
20
|
-
*
|
|
21
|
-
* check, not the contributor's: `sous repo release --check` makes it on a full
|
|
22
|
-
* clone, while a contributor usually works in the shallow checkout that
|
|
23
|
-
* `sous repo link` makes, which holds almost none of the tags.
|
|
36
|
+
* its index alone. Whether the index agrees with the tags is the maintainer's
|
|
37
|
+
* check (`sous repo release --check`, on a full clone), not the contributor's.
|
|
24
38
|
* - Every step announces itself BEFORE it runs, and a failure says exactly which
|
|
25
39
|
* steps completed. A half-finished submission (a branch pushed, no proposal
|
|
26
40
|
* opened) is a normal outcome of a network failure, and the contributor has to
|
|
@@ -34,27 +48,42 @@ import { ConfigError } from "../../errors.js";
|
|
|
34
48
|
import { runGit, type CommandRunner } from "../providers/git.js";
|
|
35
49
|
import { detectProvider } from "../providers/index.js";
|
|
36
50
|
import {
|
|
51
|
+
supportsProposals,
|
|
37
52
|
supportsSubmit,
|
|
38
53
|
type CanonicalRepo,
|
|
54
|
+
type ProposalStatus,
|
|
55
|
+
type ProposalSummary,
|
|
39
56
|
type ProviderOptions,
|
|
40
57
|
type RepoProvider,
|
|
41
58
|
type SubmitCapableProvider,
|
|
42
59
|
} from "../providers/provider.js";
|
|
43
60
|
import { INDEX_FILENAME } from "../formats/common.js";
|
|
44
|
-
import type { IndexFile } from "../formats/index-file.js";
|
|
45
|
-
import { readIndexFile } from "./index-builder.js";
|
|
46
61
|
import {
|
|
47
|
-
|
|
62
|
+
buildChangelog,
|
|
63
|
+
composeProposalBody,
|
|
64
|
+
readManifestsAt,
|
|
65
|
+
renderChangelog,
|
|
66
|
+
snapshotOf,
|
|
67
|
+
type Changelog,
|
|
68
|
+
} from "./changelog.js";
|
|
69
|
+
import {
|
|
70
|
+
branchExists,
|
|
71
|
+
commitEverything,
|
|
48
72
|
createBranch,
|
|
73
|
+
currentBranch,
|
|
49
74
|
defaultBranch,
|
|
50
75
|
forkPoint,
|
|
51
|
-
|
|
76
|
+
hasCommitIdentity,
|
|
52
77
|
pathChangedSince,
|
|
78
|
+
pathsChangedSince,
|
|
53
79
|
pushBranch,
|
|
54
80
|
remoteUrl,
|
|
55
81
|
submitBranchName,
|
|
82
|
+
switchBranch,
|
|
56
83
|
uncommittedChanges,
|
|
84
|
+
type ChangedPath,
|
|
57
85
|
} from "./git-state.js";
|
|
86
|
+
import { recipesRefusingSubmissions, type RefusingRecipe } from "./submissions.js";
|
|
58
87
|
import {
|
|
59
88
|
errorsIn,
|
|
60
89
|
hasErrors,
|
|
@@ -72,19 +101,52 @@ const FORK_REMOTE = "fork";
|
|
|
72
101
|
/** What a proposal is called when the provider does not name it. */
|
|
73
102
|
const DEFAULT_PROPOSAL_NOUN = "proposal";
|
|
74
103
|
|
|
104
|
+
/** How the contributor wants to go on after a proposal was merged. */
|
|
105
|
+
export type NextBranchChoice =
|
|
106
|
+
| { kind: "name"; name: string }
|
|
107
|
+
| { kind: "generate" }
|
|
108
|
+
| { kind: "cancel" };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every question a submission may need answered. The command supplies these:
|
|
112
|
+
* it knows whether a terminal is attached and which flags were passed, and a
|
|
113
|
+
* question that cannot be asked raises the error naming the flag that answers
|
|
114
|
+
* it. The sequencer only ever asks, and only before anything is written.
|
|
115
|
+
*/
|
|
116
|
+
export type SubmitQuestions = {
|
|
117
|
+
/** The proposal's title, when it is required and was not given. */
|
|
118
|
+
title(): Promise<string>;
|
|
119
|
+
/** The proposal's description, when it is required and was not given. */
|
|
120
|
+
body(): Promise<string>;
|
|
121
|
+
/** Whether to commit the listed paths, for `--commit`. */
|
|
122
|
+
confirmCommit(paths: ReadonlyArray<ChangedPath>): Promise<boolean>;
|
|
123
|
+
/** Whether to propose a change to recipes that say they take no proposals. */
|
|
124
|
+
proceedDespiteSubmissions(refusing: ReadonlyArray<RefusingRecipe>): Promise<boolean>;
|
|
125
|
+
/** Where to go on after the branch's proposal was merged. */
|
|
126
|
+
nextBranch(merged: ProposalSummary, generated: string): Promise<NextBranchChoice>;
|
|
127
|
+
};
|
|
128
|
+
|
|
75
129
|
/** What `submitRepo` needs to know. */
|
|
76
130
|
export type SubmitOptions = {
|
|
77
131
|
/** The repository's root directory. */
|
|
78
132
|
rootDir: string;
|
|
79
|
-
/** The title for the proposal.
|
|
133
|
+
/** The title for the proposal. Required for a new one; replaces an open one's when given. */
|
|
80
134
|
title?: string;
|
|
81
|
-
/** The
|
|
135
|
+
/** The description for the proposal. Required for a new one; replaces an open one's when given. */
|
|
82
136
|
body?: string;
|
|
83
|
-
/** Whether to open
|
|
137
|
+
/** Whether to open a new proposal as a draft. */
|
|
84
138
|
draft?: boolean;
|
|
85
|
-
/**
|
|
139
|
+
/** The branch to work with, instead of the one that is checked out. */
|
|
140
|
+
branch?: string;
|
|
141
|
+
/** When true, only report on the branch's proposal; nothing is checked, written or sent. */
|
|
142
|
+
statusOnly?: boolean;
|
|
143
|
+
/** When true, uncommitted changes are committed (after a confirmation) rather than refused. */
|
|
144
|
+
commit?: boolean;
|
|
145
|
+
/** When true, everything is checked and reported and nothing is written or sent. */
|
|
86
146
|
dryRun?: boolean;
|
|
87
|
-
/**
|
|
147
|
+
/** How questions are asked. Defaults to refusing every one of them. */
|
|
148
|
+
questions?: SubmitQuestions;
|
|
149
|
+
/** When the submission is happening; decides a generated branch name. Defaults to now. */
|
|
88
150
|
now?: Date;
|
|
89
151
|
/** How subprocesses are run. Defaults to spawning a real process. */
|
|
90
152
|
run?: CommandRunner;
|
|
@@ -94,47 +156,84 @@ export type SubmitOptions = {
|
|
|
94
156
|
onStep?: (message: string) => void;
|
|
95
157
|
/** Called with anything worth saying that is not a step. */
|
|
96
158
|
onNotice?: (message: string) => void;
|
|
159
|
+
/** Called with a warning the contributor should weigh. Defaults to `onNotice`. */
|
|
160
|
+
onWarning?: (message: string) => void;
|
|
97
161
|
};
|
|
98
162
|
|
|
163
|
+
/**
|
|
164
|
+
* How a submission ended.
|
|
165
|
+
*
|
|
166
|
+
* - `created`: a new proposal was opened (or, on a dry run, would be).
|
|
167
|
+
* - `updated`: an open proposal received new commits, a new title or a new body.
|
|
168
|
+
* - `unchanged`: an open proposal had nothing new to receive; its status is reported.
|
|
169
|
+
* - `status`: `--status` reported on the branch's proposal, or on its absence.
|
|
170
|
+
* - `cancelled`: the contributor declined a question, and nothing was written.
|
|
171
|
+
*/
|
|
172
|
+
export type SubmitOutcome = "created" | "updated" | "unchanged" | "status" | "cancelled";
|
|
173
|
+
|
|
99
174
|
/** What a submission did. */
|
|
100
175
|
export type SubmitResult = {
|
|
176
|
+
/** How it ended. */
|
|
177
|
+
outcome: SubmitOutcome;
|
|
101
178
|
/** The provider the proposal went to. */
|
|
102
179
|
provider: string;
|
|
180
|
+
/** What the provider calls a proposal. */
|
|
181
|
+
proposalNoun: string;
|
|
103
182
|
/** The repository, as the provider understands it. */
|
|
104
183
|
repo: CanonicalRepo;
|
|
105
184
|
/** The branch the change is on. */
|
|
106
185
|
branch: string;
|
|
107
186
|
/** The branch the proposal targets. */
|
|
108
187
|
baseBranch: string;
|
|
109
|
-
/** True when the change
|
|
188
|
+
/** True when the change goes through a fork rather than to the repository itself. */
|
|
110
189
|
usedFork: boolean;
|
|
111
|
-
/** The remote the branch was pushed to. */
|
|
190
|
+
/** The remote the branch was (or would be) pushed to. */
|
|
112
191
|
pushedTo: string;
|
|
113
|
-
/** The proposal's title. */
|
|
114
|
-
title
|
|
192
|
+
/** The proposal's title, when this run set one. */
|
|
193
|
+
title?: string;
|
|
115
194
|
/** The proposal's URL, when the provider reported one. */
|
|
116
195
|
url?: string;
|
|
196
|
+
/** The branch's proposal as it stood before this run, when it had one. */
|
|
197
|
+
previous?: ProposalSummary;
|
|
198
|
+
/** Where the proposal stands, when this run reported on it. */
|
|
199
|
+
status?: ProposalStatus;
|
|
200
|
+
/** The paths committed for the contributor, with `--commit`. */
|
|
201
|
+
committed?: string[];
|
|
202
|
+
/** The changelog, when this run generated one. */
|
|
203
|
+
changelog?: Changelog;
|
|
204
|
+
/** Recipes the change touches that do not take proposals. */
|
|
205
|
+
refusing: RefusingRecipe[];
|
|
117
206
|
/** Every step that completed, in order. */
|
|
118
207
|
completed: string[];
|
|
119
|
-
/** True when nothing was actually sent. */
|
|
208
|
+
/** True when nothing was actually written or sent. */
|
|
120
209
|
dryRun: boolean;
|
|
121
210
|
};
|
|
122
211
|
|
|
212
|
+
/** The questions a caller that supplied none gets: each one refuses. */
|
|
213
|
+
const REFUSING_QUESTIONS: SubmitQuestions = {
|
|
214
|
+
title: async () => {
|
|
215
|
+
throw new ConfigError("A new proposal needs a title; pass '--title'.");
|
|
216
|
+
},
|
|
217
|
+
body: async () => {
|
|
218
|
+
throw new ConfigError("A new proposal needs a description; pass '--body'.");
|
|
219
|
+
},
|
|
220
|
+
confirmCommit: async () => false,
|
|
221
|
+
proceedDespiteSubmissions: async () => false,
|
|
222
|
+
nextBranch: async () => ({ kind: "cancel" }),
|
|
223
|
+
};
|
|
224
|
+
|
|
123
225
|
/**
|
|
124
|
-
* Validates a repository and
|
|
226
|
+
* Validates a repository and carries its branch's proposal one step further:
|
|
227
|
+
* opens it, updates it, reports on it, or starts the next one.
|
|
125
228
|
*
|
|
126
|
-
* @param options - The repository, the proposal's text, and the testing seams.
|
|
229
|
+
* @param options - The repository, the proposal's text, the flags and the testing seams.
|
|
127
230
|
*/
|
|
128
231
|
export async function submitRepo(options: SubmitOptions): Promise<SubmitResult> {
|
|
129
|
-
const {
|
|
130
|
-
|
|
131
|
-
draft = false,
|
|
132
|
-
dryRun = false,
|
|
133
|
-
now = new Date(),
|
|
134
|
-
run,
|
|
135
|
-
} = options;
|
|
232
|
+
const { rootDir, draft = false, dryRun = false, now = new Date(), run } = options;
|
|
233
|
+
const questions = options.questions ?? REFUSING_QUESTIONS;
|
|
136
234
|
const step = options.onStep ?? (() => {});
|
|
137
235
|
const notice = options.onNotice ?? (() => {});
|
|
236
|
+
const warn = options.onWarning ?? notice;
|
|
138
237
|
const completed: string[] = [];
|
|
139
238
|
|
|
140
239
|
/** Runs one step, announcing it first and recording it once it succeeds. */
|
|
@@ -147,20 +246,31 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
|
|
|
147
246
|
return result;
|
|
148
247
|
};
|
|
149
248
|
|
|
249
|
+
/** Runs one read-only check, announcing it first. */
|
|
250
|
+
const check = async <T>(message: string, done: string, action: () => Promise<T>) => {
|
|
251
|
+
step(message);
|
|
252
|
+
const result = await action();
|
|
253
|
+
completed.push(done);
|
|
254
|
+
return result;
|
|
255
|
+
};
|
|
256
|
+
|
|
150
257
|
// --- Preflight: is this a repository sous can propose a change to? --------
|
|
151
258
|
//
|
|
152
|
-
// The cheap, actionable checks come first.
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
259
|
+
// The cheap, actionable checks come first. The manifests are read this early
|
|
260
|
+
// only for the contribution pointer; what the recipes say is only judged once
|
|
261
|
+
// the ground is firm.
|
|
262
|
+
|
|
263
|
+
const validation = await check(
|
|
264
|
+
"Reading the repository manifest and every recipe in it",
|
|
265
|
+
"Read the repository manifest and every recipe in it",
|
|
266
|
+
async () => validateRepo(rootDir)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const upstreamUrl = await check(
|
|
270
|
+
"Looking up where this repository was cloned from",
|
|
271
|
+
"Looked up where this repository was cloned from",
|
|
272
|
+
() => remoteUrl(rootDir, UPSTREAM_REMOTE, { run })
|
|
273
|
+
);
|
|
164
274
|
if (upstreamUrl === undefined) {
|
|
165
275
|
throw new ConfigError(
|
|
166
276
|
`This repository has no '${UPSTREAM_REMOTE}' remote, so sous cannot tell where to ` +
|
|
@@ -168,35 +278,158 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
|
|
|
168
278
|
` Add one with 'git remote add ${UPSTREAM_REMOTE} <url>', then run the command again.`
|
|
169
279
|
);
|
|
170
280
|
}
|
|
171
|
-
completed.push("Looked up where this repository was cloned from");
|
|
172
281
|
|
|
173
282
|
const provider = requireSubmitProvider(upstreamUrl, validation, options.providers);
|
|
174
283
|
const repo = provider.canonicalize(upstreamUrl);
|
|
284
|
+
const proposalNoun = provider.proposalNoun ?? DEFAULT_PROPOSAL_NOUN;
|
|
285
|
+
const tracksProposals = supportsProposals(provider);
|
|
175
286
|
|
|
176
287
|
// Everything the provider runs, it runs inside the contributor's checkout.
|
|
177
288
|
const providerOptions: ProviderOptions = { cwd: rootDir, run };
|
|
178
289
|
|
|
179
290
|
const signInLabel = provider.cli?.label ?? "the repository host's command line tool";
|
|
180
|
-
|
|
181
|
-
|
|
291
|
+
const auth = await check(
|
|
292
|
+
`Checking that ${signInLabel} is installed and signed in`,
|
|
293
|
+
`Checked that ${signInLabel} is installed and signed in`,
|
|
294
|
+
() => provider.authStatus(providerOptions)
|
|
295
|
+
);
|
|
182
296
|
if (!auth.ok) {
|
|
183
297
|
throw new ConfigError(`${auth.detail}\n` + contributePointer(validation));
|
|
184
298
|
}
|
|
185
|
-
completed.push(`Checked that ${signInLabel} is installed and signed in`);
|
|
186
299
|
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
300
|
+
const baseBranch = (await defaultBranch(rootDir, { run })) ?? "main";
|
|
301
|
+
const checkedOut = await currentBranch(rootDir, { run });
|
|
302
|
+
|
|
303
|
+
// --- Where the change goes: the repository itself, or a fork -------------
|
|
304
|
+
|
|
305
|
+
const canPush = await check(
|
|
306
|
+
"Checking whether you can push to the repository itself",
|
|
307
|
+
"Checked whether you can push to the repository itself",
|
|
308
|
+
() => provider.canPush(repo, providerOptions)
|
|
309
|
+
);
|
|
310
|
+
const usedFork = canPush === false;
|
|
311
|
+
const pushRemote = usedFork ? FORK_REMOTE : UPSTREAM_REMOTE;
|
|
312
|
+
if (canPush === undefined) {
|
|
313
|
+
notice(
|
|
314
|
+
`Sous could not tell whether you can push to ${repo.owner}/${repo.name}, so the change ` +
|
|
315
|
+
`goes to '${UPSTREAM_REMOTE}' as it stands.`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const knownForkOwner = usedFork ? await forkOwnerFromRemote(rootDir, provider, run) : undefined;
|
|
319
|
+
|
|
320
|
+
const base = {
|
|
321
|
+
provider: provider.id,
|
|
322
|
+
proposalNoun,
|
|
323
|
+
repo,
|
|
324
|
+
baseBranch,
|
|
325
|
+
usedFork,
|
|
326
|
+
pushedTo: pushRemote,
|
|
327
|
+
completed,
|
|
328
|
+
dryRun,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/** Looks up the proposal a branch was pushed for, when the provider can. */
|
|
332
|
+
const lookUp = async (branch: string): Promise<ProposalSummary | undefined> => {
|
|
333
|
+
if (!supportsProposals(provider)) return undefined;
|
|
334
|
+
return check(
|
|
335
|
+
`Looking for a ${proposalNoun} for the branch '${branch}'`,
|
|
336
|
+
`Looked for a ${proposalNoun} for the branch '${branch}'`,
|
|
337
|
+
() =>
|
|
338
|
+
provider.findProposal(
|
|
339
|
+
repo,
|
|
340
|
+
{
|
|
341
|
+
branch,
|
|
342
|
+
fromFork: usedFork,
|
|
343
|
+
...(knownForkOwner === undefined ? {} : { forkOwner: knownForkOwner }),
|
|
344
|
+
},
|
|
345
|
+
providerOptions
|
|
346
|
+
)
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** Asks the provider where a proposal stands. */
|
|
351
|
+
const statusOf = async (proposal: ProposalSummary): Promise<ProposalStatus> => {
|
|
352
|
+
if (!supportsProposals(provider)) return { proposal };
|
|
353
|
+
return check(
|
|
354
|
+
`Reading where the ${proposalNoun} stands`,
|
|
355
|
+
`Read where the ${proposalNoun} stands`,
|
|
356
|
+
() => provider.proposalStatus(repo, proposal.id, providerOptions)
|
|
357
|
+
);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// --- Status only ------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
if (options.statusOnly === true) {
|
|
363
|
+
if (!tracksProposals) {
|
|
364
|
+
throw new ConfigError(
|
|
365
|
+
`The '${provider.id}' provider cannot look a ${proposalNoun} up after it was opened, ` +
|
|
366
|
+
`so sous cannot report on one.\n` +
|
|
367
|
+
` Open the repository on its host to see where the ${proposalNoun} stands.`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const branch = options.branch ?? checkedOut;
|
|
371
|
+
if (branch === undefined) {
|
|
372
|
+
throw new ConfigError(
|
|
373
|
+
"No branch is checked out, so there is no proposal to report on.\n" +
|
|
374
|
+
" Name the branch with '--branch <name>'."
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
const found = await lookUp(branch);
|
|
378
|
+
const status = found === undefined ? undefined : await statusOf(found);
|
|
379
|
+
return {
|
|
380
|
+
...base,
|
|
381
|
+
outcome: "status",
|
|
382
|
+
branch,
|
|
383
|
+
...(found?.url === undefined ? {} : { url: found.url }),
|
|
384
|
+
...(found === undefined ? {} : { previous: found }),
|
|
385
|
+
...(status === undefined ? {} : { status }),
|
|
386
|
+
refusing: [],
|
|
387
|
+
dryRun: true,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- The working tree -------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
const uncommitted = await check(
|
|
394
|
+
"Checking that everything is committed",
|
|
395
|
+
"Checked that everything is committed",
|
|
396
|
+
() => uncommittedChanges(rootDir, { run })
|
|
397
|
+
);
|
|
398
|
+
const toCommit = options.commit === true ? uncommitted : [];
|
|
399
|
+
|
|
400
|
+
if (uncommitted.length > 0 && options.commit !== true) {
|
|
401
|
+
const listed = uncommitted.map((entry) => ` ${entry.path}`).join("\n");
|
|
191
402
|
throw new ConfigError(
|
|
192
403
|
"Cannot propose a change while the working tree has uncommitted changes.\n\n" +
|
|
193
404
|
`${listed}\n\n` +
|
|
194
405
|
" A proposal is made of commits, so everything it should carry has to be " +
|
|
195
406
|
"committed first.\n" +
|
|
196
|
-
"
|
|
407
|
+
" Commit these yourself, or pass '--commit' to have sous commit them, then run " +
|
|
408
|
+
"the command again."
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
if (options.commit === true && uncommitted.length === 0) {
|
|
412
|
+
notice("Everything is already committed, so '--commit' has nothing to commit.");
|
|
413
|
+
}
|
|
414
|
+
if (toCommit.some((entry) => entry.path === INDEX_FILENAME)) {
|
|
415
|
+
throw indexEditedError(`git checkout HEAD -- ${INDEX_FILENAME}`);
|
|
416
|
+
}
|
|
417
|
+
if (toCommit.length > 0) {
|
|
418
|
+
// Checked before anything is written, so a missing identity is reported
|
|
419
|
+
// rather than surfacing as git's own error halfway through.
|
|
420
|
+
const identified = await check(
|
|
421
|
+
"Checking that git knows who is committing",
|
|
422
|
+
"Checked that git knows who is committing",
|
|
423
|
+
() => hasCommitIdentity(rootDir, { run })
|
|
197
424
|
);
|
|
425
|
+
if (!identified) {
|
|
426
|
+
throw new ConfigError(
|
|
427
|
+
"Sous cannot commit for you, because git cannot work out who is committing.\n" +
|
|
428
|
+
" Set it with 'git config user.name \"Your Name\"' and " +
|
|
429
|
+
"'git config user.email you@example.com', then run the command again."
|
|
430
|
+
);
|
|
431
|
+
}
|
|
198
432
|
}
|
|
199
|
-
completed.push("Checked that everything is committed");
|
|
200
433
|
|
|
201
434
|
// --- Validate what is about to be proposed --------------------------------
|
|
202
435
|
|
|
@@ -204,108 +437,245 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
|
|
|
204
437
|
assertRepoValidates(validation);
|
|
205
438
|
completed.push("Checked that every recipe describes itself correctly");
|
|
206
439
|
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
`
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
completed.push(`Checked that ${INDEX_FILENAME} was left alone`);
|
|
440
|
+
const since = await check(
|
|
441
|
+
`Checking that ${INDEX_FILENAME} was left alone`,
|
|
442
|
+
`Checked that ${INDEX_FILENAME} was left alone`,
|
|
443
|
+
async () => {
|
|
444
|
+
const point = await forkPoint(rootDir, UPSTREAM_REMOTE, baseBranch, { run });
|
|
445
|
+
if (point === undefined) {
|
|
446
|
+
notice(
|
|
447
|
+
`This checkout holds no copy of '${UPSTREAM_REMOTE}/${baseBranch}', so sous could not ` +
|
|
448
|
+
`check whether ${INDEX_FILENAME} was changed, which recipes the change touches, or ` +
|
|
449
|
+
`what merging it changes.`
|
|
450
|
+
);
|
|
451
|
+
} else if (await pathChangedSince(rootDir, point, INDEX_FILENAME, { run })) {
|
|
452
|
+
throw indexEditedError(`git checkout ${point.slice(0, 12)} -- ${INDEX_FILENAME}`);
|
|
453
|
+
}
|
|
454
|
+
return point;
|
|
455
|
+
}
|
|
456
|
+
);
|
|
225
457
|
|
|
226
|
-
//
|
|
458
|
+
// Every path the change touches: its commits, and whatever --commit adds.
|
|
459
|
+
const changedPaths = [
|
|
460
|
+
...(since === undefined ? [] : await pathsChangedSince(rootDir, since, { run })),
|
|
461
|
+
...toCommit.map((entry) => entry.path),
|
|
462
|
+
];
|
|
227
463
|
|
|
228
|
-
|
|
229
|
-
let branch = checkedOut;
|
|
464
|
+
// --- Recipes that take no proposals ---------------------------------------
|
|
230
465
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
await doStep(`Creating the branch '${branch}' from the current commit`, () =>
|
|
237
|
-
createBranch(rootDir, branch!, { run })
|
|
238
|
-
);
|
|
466
|
+
const refusing = recipesRefusingSubmissions(validation, changedPaths);
|
|
467
|
+
if (refusing.length > 0) {
|
|
468
|
+
warn(describeRefusing(refusing));
|
|
469
|
+
if (!(await questions.proceedDespiteSubmissions(refusing))) {
|
|
470
|
+
return cancelled("You chose not to propose a change to those recipes.");
|
|
239
471
|
}
|
|
240
472
|
}
|
|
241
473
|
|
|
242
|
-
|
|
243
|
-
options.title ?? (await lastCommitSubject(rootDir, { run })) ?? defaultTitle(validation);
|
|
244
|
-
const body = options.body ?? defaultBody(validation, readIndexFile(rootDir));
|
|
474
|
+
// --- Which branch, and which proposal -------------------------------------
|
|
245
475
|
|
|
246
|
-
|
|
476
|
+
/** Ends the run with nothing written, saying why. */
|
|
477
|
+
function cancelled(reason: string): SubmitResult {
|
|
478
|
+
notice(`${reason} Nothing was written.`);
|
|
479
|
+
return {
|
|
480
|
+
...base,
|
|
481
|
+
outcome: "cancelled",
|
|
482
|
+
branch: options.branch ?? checkedOut ?? baseBranch,
|
|
483
|
+
refusing,
|
|
484
|
+
dryRun: true,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
247
487
|
|
|
248
|
-
|
|
249
|
-
let
|
|
250
|
-
let
|
|
488
|
+
// The plan for the branch: stay, switch to an existing one, or create one.
|
|
489
|
+
let branch: string;
|
|
490
|
+
let branchAction: "stay" | "switch" | "create";
|
|
491
|
+
if (options.branch !== undefined && options.branch !== checkedOut) {
|
|
492
|
+
branch = options.branch;
|
|
493
|
+
branchAction = (await branchExists(rootDir, branch, { run })) ? "switch" : "create";
|
|
494
|
+
} else if (checkedOut === undefined || checkedOut === baseBranch) {
|
|
495
|
+
branch = options.branch ?? submitBranchName(now);
|
|
496
|
+
branchAction = "create";
|
|
497
|
+
} else {
|
|
498
|
+
branch = checkedOut;
|
|
499
|
+
branchAction = "stay";
|
|
500
|
+
}
|
|
251
501
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
502
|
+
let previous: ProposalSummary | undefined;
|
|
503
|
+
if (branchAction !== "create") {
|
|
504
|
+
previous = await lookUp(branch);
|
|
505
|
+
} else if (tracksProposals && options.branch !== undefined) {
|
|
506
|
+
// A named branch that does not exist here may still have been pushed from
|
|
507
|
+
// another machine, with a proposal behind it.
|
|
508
|
+
previous = await lookUp(branch);
|
|
509
|
+
}
|
|
510
|
+
if (!tracksProposals) {
|
|
511
|
+
notice(
|
|
512
|
+
`The '${provider.id}' provider cannot look for a ${proposalNoun} that is already open, ` +
|
|
513
|
+
`so sous opens a new one. If this branch already has one, pushing updates it and ` +
|
|
514
|
+
`opening another may be refused.`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
255
517
|
|
|
256
|
-
|
|
518
|
+
let action: "create" | "update" = previous?.state === "open" ? "update" : "create";
|
|
519
|
+
|
|
520
|
+
if (previous?.state === "merged") {
|
|
257
521
|
notice(
|
|
258
|
-
`
|
|
259
|
-
|
|
522
|
+
`The ${proposalNoun} for '${branch}' was merged` +
|
|
523
|
+
(previous.url === undefined ? "." : `: ${previous.url}.`) +
|
|
524
|
+
" A merged branch takes no further changes, so the next change goes on a new branch."
|
|
260
525
|
);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
notice(
|
|
266
|
-
`You cannot push to ${repo.owner}/${repo.name}, so the change would go through ` +
|
|
267
|
-
`a fork on your own account.`
|
|
268
|
-
);
|
|
269
|
-
} else {
|
|
270
|
-
forkOwner = await prepareFork(
|
|
271
|
-
rootDir,
|
|
272
|
-
repo,
|
|
273
|
-
provider,
|
|
274
|
-
providerOptions,
|
|
275
|
-
run,
|
|
276
|
-
doStep
|
|
277
|
-
);
|
|
526
|
+
const generated = submitBranchName(now);
|
|
527
|
+
const choice = await questions.nextBranch(previous, generated);
|
|
528
|
+
if (choice.kind === "cancel") {
|
|
529
|
+
return cancelled("You chose not to continue on a new branch.");
|
|
278
530
|
}
|
|
531
|
+
branch = choice.kind === "name" ? choice.name.trim() : generated;
|
|
532
|
+
branchAction = "create";
|
|
533
|
+
action = "create";
|
|
534
|
+
} else if (previous?.state === "closed") {
|
|
535
|
+
notice(
|
|
536
|
+
`The ${proposalNoun} for '${branch}' was closed without being merged` +
|
|
537
|
+
(previous.url === undefined ? "." : `: ${previous.url}.`) +
|
|
538
|
+
` A fresh ${proposalNoun} is opened for the branch.`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// --- The text ---------------------------------------------------------------
|
|
543
|
+
//
|
|
544
|
+
// A new proposal needs a title and a description written by the person
|
|
545
|
+
// proposing it, and so does a commit sous makes for them; sous never derives
|
|
546
|
+
// either from commit messages. An update takes them only when given.
|
|
547
|
+
|
|
548
|
+
const needsText = action === "create" || toCommit.length > 0;
|
|
549
|
+
let title = blankToUndefined(options.title);
|
|
550
|
+
let description = blankToUndefined(options.body);
|
|
551
|
+
if (needsText && title === undefined) title = (await questions.title()).trim();
|
|
552
|
+
if (needsText && description === undefined) description = (await questions.body()).trim();
|
|
553
|
+
if (needsText && (title === undefined || title.length === 0)) {
|
|
554
|
+
throw new ConfigError(`A new ${proposalNoun} needs a title; pass '--title'.`);
|
|
555
|
+
}
|
|
556
|
+
if (needsText && (description === undefined || description.length === 0)) {
|
|
557
|
+
throw new ConfigError(`A new ${proposalNoun} needs a description; pass '--body'.`);
|
|
279
558
|
}
|
|
280
559
|
|
|
560
|
+
if (toCommit.length > 0 && !(await questions.confirmCommit(toCommit))) {
|
|
561
|
+
return cancelled("You chose not to commit those changes.");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// --- The changelog ----------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
const changelog = buildChangelog({
|
|
567
|
+
baseBranch,
|
|
568
|
+
base: since === undefined ? undefined : await readManifestsAt(rootDir, since, { run }),
|
|
569
|
+
head: snapshotOf(validation),
|
|
570
|
+
changedPaths,
|
|
571
|
+
});
|
|
572
|
+
const proposalBody =
|
|
573
|
+
description === undefined ? undefined : composeProposalBody(description, changelog);
|
|
574
|
+
|
|
575
|
+
// --- A dry run stops here ---------------------------------------------------
|
|
576
|
+
|
|
281
577
|
if (dryRun) {
|
|
578
|
+
if (branchAction === "create") {
|
|
579
|
+
notice(`A branch named '${branch}' would be created from the current commit.`);
|
|
580
|
+
} else if (branchAction === "switch") {
|
|
581
|
+
notice(`The branch '${branch}' would be checked out.`);
|
|
582
|
+
}
|
|
583
|
+
if (toCommit.length > 0) {
|
|
584
|
+
notice(`${describeCount(toCommit.length, "path")} would be committed.`);
|
|
585
|
+
}
|
|
586
|
+
if (usedFork) {
|
|
587
|
+
notice(
|
|
588
|
+
`You cannot push to ${repo.owner}/${repo.name}, so the change would go through a ` +
|
|
589
|
+
`fork on your own account.`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
282
592
|
notice("Nothing was sent; this was a dry run.");
|
|
283
593
|
return {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
branch
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
594
|
+
...base,
|
|
595
|
+
outcome: action === "update" ? "updated" : "created",
|
|
596
|
+
branch,
|
|
597
|
+
...(title === undefined ? {} : { title }),
|
|
598
|
+
...(previous === undefined ? {} : { previous }),
|
|
599
|
+
...(previous?.url === undefined || action !== "update" ? {} : { url: previous.url }),
|
|
600
|
+
changelog,
|
|
601
|
+
refusing,
|
|
292
602
|
dryRun: true,
|
|
293
603
|
};
|
|
294
604
|
}
|
|
295
605
|
|
|
296
|
-
|
|
297
|
-
|
|
606
|
+
// --- Write: the branch, the commit ------------------------------------------
|
|
607
|
+
|
|
608
|
+
if (branchAction === "create") {
|
|
609
|
+
await doStep(`Creating the branch '${branch}' from the current commit`, () =>
|
|
610
|
+
createBranch(rootDir, branch, { run })
|
|
611
|
+
);
|
|
612
|
+
} else if (branchAction === "switch") {
|
|
613
|
+
await doStep(`Checking out the branch '${branch}'`, () =>
|
|
614
|
+
switchBranch(rootDir, branch, { run })
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (toCommit.length > 0) {
|
|
619
|
+
await doStep(`Committing ${describeCount(toCommit.length, "path")}`, () =>
|
|
620
|
+
commitEverything(rootDir, commitMessage(title!, description!, changelog), { run })
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// --- Fork, push, propose ----------------------------------------------------
|
|
625
|
+
|
|
626
|
+
let forkOwner = knownForkOwner;
|
|
627
|
+
if (usedFork) {
|
|
628
|
+
forkOwner = await prepareFork(rootDir, repo, provider, providerOptions, run, doStep);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const pushed = await doStep(`Pushing '${branch}' to '${pushRemote}'`, () =>
|
|
632
|
+
pushBranch(rootDir, pushRemote, branch, { run }).catch((error: unknown) => {
|
|
633
|
+
throw new ConfigError(
|
|
634
|
+
`${error instanceof Error ? error.message : String(error)}\n` +
|
|
635
|
+
` Sous never forces a push. When the branch on '${pushRemote}' holds commits yours ` +
|
|
636
|
+
`lacks (a maintainer may have pushed to it), bring them in with ` +
|
|
637
|
+
`'git pull ${pushRemote} ${branch}', then run the command again.`
|
|
638
|
+
);
|
|
639
|
+
})
|
|
298
640
|
);
|
|
299
641
|
|
|
300
|
-
|
|
642
|
+
if (action === "update" && previous !== undefined && supportsProposals(provider)) {
|
|
643
|
+
const replace = {
|
|
644
|
+
...(title === undefined ? {} : { title }),
|
|
645
|
+
...(proposalBody === undefined ? {} : { body: proposalBody }),
|
|
646
|
+
};
|
|
647
|
+
let url = previous.url;
|
|
648
|
+
if (Object.keys(replace).length > 0) {
|
|
649
|
+
const updated = await doStep(`Updating the ${proposalNoun}'s title and body`, () =>
|
|
650
|
+
provider.updateProposal(repo, previous!.id, replace, providerOptions)
|
|
651
|
+
);
|
|
652
|
+
url = updated.url ?? url;
|
|
653
|
+
}
|
|
654
|
+
const changed = pushed === "updated" || Object.keys(replace).length > 0;
|
|
655
|
+
const status = await statusOf(previous);
|
|
656
|
+
return {
|
|
657
|
+
...base,
|
|
658
|
+
outcome: changed ? "updated" : "unchanged",
|
|
659
|
+
branch,
|
|
660
|
+
...(title === undefined ? {} : { title }),
|
|
661
|
+
...(url === undefined ? {} : { url }),
|
|
662
|
+
previous,
|
|
663
|
+
status,
|
|
664
|
+
...(toCommit.length > 0 ? { committed: toCommit.map((entry) => entry.path) } : {}),
|
|
665
|
+
changelog,
|
|
666
|
+
refusing,
|
|
667
|
+
dryRun: false,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
301
671
|
const proposed = await doStep(`Opening a ${proposalNoun} for review`, () =>
|
|
302
672
|
provider.proposeChange(
|
|
303
673
|
repo,
|
|
304
674
|
{
|
|
305
|
-
branch
|
|
675
|
+
branch,
|
|
306
676
|
base: baseBranch,
|
|
307
|
-
title
|
|
308
|
-
body
|
|
677
|
+
title: title!,
|
|
678
|
+
body: proposalBody!,
|
|
309
679
|
draft,
|
|
310
680
|
...(forkOwner === undefined ? {} : { head: { owner: forkOwner } }),
|
|
311
681
|
},
|
|
@@ -315,15 +685,15 @@ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult>
|
|
|
315
685
|
if (proposed.url === undefined) notice(proposed.detail);
|
|
316
686
|
|
|
317
687
|
return {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
branch
|
|
321
|
-
|
|
322
|
-
usedFork,
|
|
323
|
-
pushedTo: pushRemote,
|
|
324
|
-
title,
|
|
688
|
+
...base,
|
|
689
|
+
outcome: "created",
|
|
690
|
+
branch,
|
|
691
|
+
title: title!,
|
|
325
692
|
...(proposed.url === undefined ? {} : { url: proposed.url }),
|
|
326
|
-
|
|
693
|
+
...(previous === undefined ? {} : { previous }),
|
|
694
|
+
...(toCommit.length > 0 ? { committed: toCommit.map((entry) => entry.path) } : {}),
|
|
695
|
+
changelog,
|
|
696
|
+
refusing,
|
|
327
697
|
dryRun: false,
|
|
328
698
|
};
|
|
329
699
|
}
|
|
@@ -340,11 +710,45 @@ function assertRepoValidates(validation: RepoValidation): void {
|
|
|
340
710
|
);
|
|
341
711
|
}
|
|
342
712
|
|
|
713
|
+
/** The refusal for a change that edits the index, naming how to put it back. */
|
|
714
|
+
function indexEditedError(restore: string): ConfigError {
|
|
715
|
+
return new ConfigError(
|
|
716
|
+
`This change edits ${INDEX_FILENAME}. The index is written by the repository's own ` +
|
|
717
|
+
`release, after a change is merged, so a proposal leaves it as it found it.\n` +
|
|
718
|
+
` Restore it with '${restore}', commit that, then run the command again.`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
343
722
|
/** Renders a list of problems as an indented block. */
|
|
344
723
|
function renderProblems(problems: ReadonlyArray<ValidationProblem>): string {
|
|
345
724
|
return problems.map((problem) => ` ${problem.where}: ${problem.message}`).join("\n");
|
|
346
725
|
}
|
|
347
726
|
|
|
727
|
+
/**
|
|
728
|
+
* Says which recipes a change touches although they take no proposals, and
|
|
729
|
+
* where each asks for changes to go instead.
|
|
730
|
+
*
|
|
731
|
+
* @param refusing - The recipes, as `recipesRefusingSubmissions` found them.
|
|
732
|
+
*/
|
|
733
|
+
export function describeRefusing(refusing: ReadonlyArray<RefusingRecipe>): string {
|
|
734
|
+
const lines = [
|
|
735
|
+
refusing.length === 1
|
|
736
|
+
? "This change touches a recipe that does not take proposed changes:"
|
|
737
|
+
: "This change touches recipes that do not take proposed changes:",
|
|
738
|
+
];
|
|
739
|
+
for (const recipe of refusing) {
|
|
740
|
+
const where =
|
|
741
|
+
recipe.declaredBy === "recipe" ? "its own manifest" : "the repository manifest";
|
|
742
|
+
lines.push(` ${recipe.key} (${recipe.path}), as ${where} says.`);
|
|
743
|
+
if (recipe.instead !== undefined) lines.push(` Instead: ${recipe.instead}`);
|
|
744
|
+
}
|
|
745
|
+
lines.push(
|
|
746
|
+
"Merging such a change usually breaks whatever publishes those recipes, and the " +
|
|
747
|
+
"repository's own check refuses it."
|
|
748
|
+
);
|
|
749
|
+
return lines.join("\n");
|
|
750
|
+
}
|
|
751
|
+
|
|
348
752
|
/**
|
|
349
753
|
* The provider that will carry the proposal, or a ConfigError pointing the
|
|
350
754
|
* contributor at whatever route the repository documents instead. The feature
|
|
@@ -386,8 +790,51 @@ function contributePointer(validation: RepoValidation): string {
|
|
|
386
790
|
return ` This repository asks that changes be sent this way:\n ${contribute}`;
|
|
387
791
|
}
|
|
388
792
|
|
|
793
|
+
/** A string with its surrounding whitespace removed, or undefined when nothing is left. */
|
|
794
|
+
function blankToUndefined(value: string | undefined): string | undefined {
|
|
795
|
+
if (value === undefined) return undefined;
|
|
796
|
+
const trimmed = value.trim();
|
|
797
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** "1 path", "3 paths". */
|
|
801
|
+
function describeCount(count: number, noun: string): string {
|
|
802
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* The message `--commit` commits with: the title as the subject, then the
|
|
807
|
+
* description, then the changelog.
|
|
808
|
+
*
|
|
809
|
+
* @param title - The proposal's title.
|
|
810
|
+
* @param description - The contributor's description.
|
|
811
|
+
* @param changelog - The generated changelog.
|
|
812
|
+
*/
|
|
813
|
+
export function commitMessage(title: string, description: string, changelog: Changelog): string {
|
|
814
|
+
return `${title}\n\n${description}\n\n${renderChangelog(changelog)}\n`;
|
|
815
|
+
}
|
|
816
|
+
|
|
389
817
|
// --- Delegation helpers -------------------------------------------------------------------------
|
|
390
818
|
|
|
819
|
+
/**
|
|
820
|
+
* The owner of the fork a `fork` remote already points at, which is the head
|
|
821
|
+
* owner a proposal from it carries. Undefined when there is no such remote, or
|
|
822
|
+
* when its URL is not one the provider can read.
|
|
823
|
+
*/
|
|
824
|
+
async function forkOwnerFromRemote(
|
|
825
|
+
rootDir: string,
|
|
826
|
+
provider: RepoProvider,
|
|
827
|
+
run: CommandRunner | undefined
|
|
828
|
+
): Promise<string | undefined> {
|
|
829
|
+
const url = await remoteUrl(rootDir, FORK_REMOTE, { run });
|
|
830
|
+
if (url === undefined || !provider.matches(url)) return undefined;
|
|
831
|
+
try {
|
|
832
|
+
return provider.canonicalize(url).owner;
|
|
833
|
+
} catch {
|
|
834
|
+
return undefined;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
391
838
|
/**
|
|
392
839
|
* Asks the provider to fork the repository onto the contributor's own account,
|
|
393
840
|
* then makes sure a git remote points at whatever came back. The fork itself is
|
|
@@ -425,46 +872,6 @@ async function prepareFork(
|
|
|
425
872
|
return fork.owner;
|
|
426
873
|
}
|
|
427
874
|
|
|
428
|
-
/** The title used when there is no commit subject to borrow. */
|
|
429
|
-
function defaultTitle(validation: RepoValidation): string {
|
|
430
|
-
return `Update the ${validation.manifest.name} recipes`;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* The versions merging this change would publish: every recipe whose manifest
|
|
435
|
-
* declares a version the committed index does not list yet. Worked out from
|
|
436
|
-
* the manifests and the index alone, so it needs none of the tags.
|
|
437
|
-
*/
|
|
438
|
-
function versionsToPublish(
|
|
439
|
-
validation: RepoValidation,
|
|
440
|
-
index: IndexFile | undefined
|
|
441
|
-
): Array<{ key: string; version: string }> {
|
|
442
|
-
return validation.recipes
|
|
443
|
-
.filter((recipe) => index?.recipes[recipe.key]?.versions[recipe.manifest.version] === undefined)
|
|
444
|
-
.map((recipe) => ({ key: recipe.key, version: recipe.manifest.version }));
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/** The body sous writes when the contributor did not supply one. */
|
|
448
|
-
function defaultBody(validation: RepoValidation, index: IndexFile | undefined): string {
|
|
449
|
-
const lines = [
|
|
450
|
-
`Proposed with 'sous repo submit' from the ${validation.manifest.name} repository.`,
|
|
451
|
-
"",
|
|
452
|
-
"Recipes in this repository:",
|
|
453
|
-
];
|
|
454
|
-
for (const recipe of validation.recipes) {
|
|
455
|
-
lines.push(`- ${recipe.key} at version ${recipe.manifest.version}`);
|
|
456
|
-
}
|
|
457
|
-
const toPublish = versionsToPublish(validation, index);
|
|
458
|
-
if (toPublish.length > 0) {
|
|
459
|
-
lines.push("");
|
|
460
|
-
lines.push("Versions this proposal would publish once it is merged and tagged:");
|
|
461
|
-
for (const entry of toPublish) {
|
|
462
|
-
lines.push(`- ${entry.key} ${entry.version}`);
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
return lines.join("\n");
|
|
466
|
-
}
|
|
467
|
-
|
|
468
875
|
/**
|
|
469
876
|
* Turns a mid-flight failure into an error that says what already happened.
|
|
470
877
|
* A pushed branch with no proposal behind it is a state the contributor has to
|