Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

367 linesCodeBlameRaw
1import type { CommitMessageDefault, MergeChoices, MergeMethod, RepoSettings } from "@g1t/contracts";
2
3/** Every way a pull request can land, in the order they are offered. */
4export const MERGE_METHODS: readonly MergeMethod[] = ["merge", "squash", "rebase", "fast_forward"];
5
6/** How each method is named and explained: in settings, on the merge button, and once merged. */
7export const METHOD_INFO: Record<
8 MergeMethod,
9 { label: string; short: string; action: string; past: string; about: string; setting: keyof RepoSettings }
10> = {
11 merge: {
12 label: "Merge commits",
13 short: "Merge",
14 action: "Create a merge commit",
15 past: "a merge commit",
16 about: "Every commit of the pull request is added to the base branch through a merge commit.",
17 setting: "allowMergeCommit",
18 },
19 squash: {
20 label: "Squash merging",
21 short: "Squash",
22 action: "Squash and merge",
23 past: "squash",
24 about: "The pull request's commits are combined into one commit on the base branch.",
25 setting: "allowSquashMerge",
26 },
27 rebase: {
28 label: "Rebase merging",
29 short: "Rebase",
30 action: "Rebase and merge",
31 past: "rebase",
32 about: "The pull request's commits are made again, one by one, on top of the base branch, with no merge commit.",
33 setting: "allowRebaseMerge",
34 },
35 fast_forward: {
36 label: "Fast-forward merging",
37 short: "Fast-forward",
38 action: "Fast-forward",
39 past: "fast-forward",
40 about:
41 "The base branch moves to the pull request's head as it is: the same commits, the same hashes, nothing new written. Only possible when the pull request is up to date with its base.",
42 setting: "allowFastForward",
43 },
44};
45
46/** Whether a string is a merge method. */
47export function isMergeMethod(value: unknown): value is MergeMethod {
48 return typeof value === "string" && (MERGE_METHODS as readonly string[]).includes(value);
49}
50
51/** The methods a repository's settings allow, in order. */
52export function allowedMethods(settings: Pick<RepoSettings, "allowMergeCommit" | "allowSquashMerge" | "allowRebaseMerge" | "allowFastForward">): MergeMethod[] {
53 return MERGE_METHODS.filter((method) => settings[METHOD_INFO[method].setting as keyof typeof settings]);
54}
55
56/** Whether a method writes a commit whose message can be chosen. */
57export function writesMessage(method: MergeMethod): boolean {
58 return method === "merge" || method === "squash";
59}
60
61/** The default commit message choices for merge commits, then squash commits. */
62export const MESSAGE_CHOICES: Record<"merge" | "squash", { value: CommitMessageDefault; label: string }[]> = {
63 merge: [
64 { value: "default", label: "Default message" },
65 { value: "pull_title", label: "Pull request title" },
66 { value: "pull_title_and_description", label: "Pull request title and description" },
67 { value: "custom", label: "Custom template" },
68 ],
69 squash: [
70 { value: "default", label: "Default message" },
71 { value: "pull_title", label: "Pull request title" },
72 { value: "pull_title_and_commits", label: "Pull request title and commit details" },
73 { value: "pull_title_and_description", label: "Pull request title and description" },
74 { value: "custom", label: "Custom template" },
75 ],
76};
77
78/** What a custom template can say, each filled in from the pull request. */
79export const PLACEHOLDERS: { name: string; about: string }[] = [
80 { name: "title", about: "Its title" },
81 { name: "number", about: "Its number, without #" },
82 { name: "body", about: "Its description" },
83 { name: "branch", about: "The branch it merges from" },
84 { name: "head_repo", about: "The repository that branch is in, as owner/name" },
85 { name: "base", about: "The branch it merges into" },
86 { name: "author", about: "Its author's username" },
87 { name: "commits", about: "Its commits' subjects, one * subject line each" },
88 { name: "issues", about: "The issues it closes, as #12, #14" },
89 { name: "co_authors", about: "A Co-authored-by line for each author of its commits, each once" },
90];
91
92/** One commit a pull request adds, as its message says. */
93export type PullCommit = { message: string; authorEmail: string | null };
94
95/**
96 * What a default message is made from: the work service's `MessageFacts`
97 * (services/work/src/merge_message.rs), which this mirrors exactly.
98 */
99export type TemplatePull = {
100 number: number;
101 title: string;
102 body: string;
103 /** The branch holding the change. */
104 branch: string;
105 /** Who the branch belongs to: the workspace of the repository or fork it is in. */
106 headOwner: string;
107 /** The repository or fork holding it, `owner/name`. */
108 headRepo: string;
109 /** The branch it merges into. */
110 base: string;
111 /** Its author's username. */
112 author: string;
113 /** Its commits, oldest first, merge commits left out. */
114 commits: PullCommit[];
115 /** The issues it closes. */
116 issues: number[];
117};
118
119/** A made-up pull request, to show what a choice or a template says. */
120export const SAMPLE_PULL: TemplatePull = {
121 number: 128,
122 title: "Retry failed webhook deliveries",
123 body: "Deliveries that time out are retried three times, with backoff.",
124 branch: "webhook-retries",
125 headOwner: "acme",
126 headRepo: "acme/api",
127 base: "main",
128 author: "ana",
129 commits: [
130 { message: "Retry deliveries that time out", authorEmail: "ana@example.com" },
131 { message: "Back off between retries\n\nDoubling each time, up to a minute.", authorEmail: "ben@example.com" },
132 { message: "Test the retry schedule", authorEmail: "ana@example.com" },
133 ],
134 issues: [97],
135};
136
137/** A commit message as a title (its first line) and the rest. */
138export type CommitText = { title: string; message: string };
139
140/** A whole message as its title (first line) and body (the rest, blank lines around it dropped). */
141export function splitMessage(text: string): CommitText {
142 const whole = text.trim();
143 const at = whole.indexOf("\n");
144 return at < 0 ? { title: whole, message: "" } : { title: whole.slice(0, at).trim(), message: whole.slice(at + 1).trim() };
145}
146
147/** A commit message's first line. */
148function subject(message: string): string {
149 return (message.split("\n")[0] ?? "").trim();
150}
151
152/** `* subject` for each commit, one per line. */
153function bullets(commits: readonly PullCommit[]): string {
154 return commits
155 .map((commit) => subject(commit.message))
156 .filter((line) => line !== "")
157 .map((line) => `* ${line}`)
158 .join("\n");
159}
160
161/** `Co-authored-by:` lines for the commits' authors, each once. */
162function coAuthors(commits: readonly PullCommit[]): string {
163 const seen: string[] = [];
164 for (const commit of commits) {
165 if (commit.authorEmail == null) continue;
166 const email = commit.authorEmail.trim().toLowerCase();
167 if (email !== "" && !seen.includes(email)) seen.push(email);
168 }
169 return seen.map((email) => `Co-authored-by: ${email.split("@")[0]} <${email}>`).join("\n");
170}
171
172/**
173 * `template` with each `{name}` replaced once, left to right: what a value
174 * holds is never filled in again. Unknown names stay as written.
175 */
176function fill(template: string, values: Record<string, string>): string {
177 let out = "";
178 let rest = template;
179 for (let open = rest.indexOf("{"); open >= 0; open = rest.indexOf("{")) {
180 out += rest.slice(0, open);
181 const after = rest.slice(open + 1);
182 const close = after.indexOf("}");
183 const name = close >= 0 ? after.slice(0, close) : null;
184 if (name != null && Object.prototype.hasOwnProperty.call(values, name)) {
185 out += values[name];
186 rest = after.slice(close + 1);
187 } else {
188 out += "{";
189 rest = after;
190 }
191 }
192 return out + rest;
193}
194
195/** A template with its placeholders filled in: its first line the title, the rest the message. */
196export function fillTemplate(template: string, pull: TemplatePull): CommitText {
197 return splitMessage(
198 fill(template, {
199 title: pull.title.trim(),
200 number: String(pull.number),
201 body: pull.body.trim(),
202 branch: pull.branch,
203 head_repo: pull.headRepo,
204 base: pull.base,
205 author: pull.author,
206 commits: bullets(pull.commits),
207 issues: pull.issues.map((number) => `#${number}`).join(", "),
208 co_authors: coAuthors(pull.commits),
209 }),
210 );
211}
212
213/** The repository's template filled in, or null when there is none or its title comes out empty. */
214function custom(template: string | null, pull: TemplatePull): CommitText | null {
215 if (template == null) return null;
216 const filled = fillTemplate(template, pull);
217 return filled.title === "" ? null : filled;
218}
219
220/**
221 * What a merge or squash commit says by default, as the repository chose:
222 * exactly what the work service's `default_message` makes.
223 */
224export function defaultMessage(
225 method: "merge" | "squash",
226 choice: CommitMessageDefault,
227 template: string | null,
228 pull: TemplatePull,
229): CommitText {
230 const numbered = `${pull.title.trim()} (#${pull.number})`;
231 const description = pull.body.trim();
232 if (method === "merge") {
233 // "Merge pull request #12 from owner/branch", and its title below.
234 const merged = { title: `Merge pull request #${pull.number} from ${pull.headOwner}/${pull.branch}`, message: pull.title.trim() };
235 switch (choice) {
236 case "pull_title":
237 return { title: numbered, message: "" };
238 case "pull_title_and_description":
239 return { title: numbered, message: description };
240 case "custom":
241 return custom(template, pull) ?? merged;
242 // Listing commits is for squashes; settings refuse it for merges.
243 default:
244 return merged;
245 }
246 }
247 // Its title and number; below, its commits' subjects when it has more than one.
248 const squashed = { title: numbered, message: pull.commits.length > 1 ? bullets(pull.commits) : "" };
249 switch (choice) {
250 case "pull_title":
251 return { title: numbered, message: "" };
252 case "pull_title_and_description":
253 return { title: numbered, message: description };
254 case "pull_title_and_commits":
255 return {
256 title: numbered,
257 message: pull.commits
258 .map((commit) => commit.message.trim())
259 .filter((message) => message !== "")
260 .join("\n\n"),
261 };
262 case "custom":
263 return custom(template, pull) ?? squashed;
264 default:
265 return squashed;
266 }
267}
268
269const REMEMBERED = "g1t:merge-method:";
270
271/** The method this viewer last merged with in a repository, if they did and it is still allowed. */
272export function rememberedMethod(repo: string, allowed: readonly MergeMethod[]): MergeMethod | null {
273 try {
274 const value = window.localStorage.getItem(REMEMBERED + repo);
275 return isMergeMethod(value) && allowed.includes(value) ? value : null;
276 } catch {
277 return null;
278 }
279}
280
281/** Remembers the method this viewer chose in a repository, for next time. */
282export function rememberMethod(repo: string, method: MergeMethod) {
283 try {
284 window.localStorage.setItem(REMEMBERED + repo, method);
285 } catch {
286 // A private window: the choice lasts for this page.
287 }
288}
289
290/**
291 * What the merge box offers on one pull request: the methods it may merge
292 * with (the repository's settings, narrowed by its base branch's rules), the
293 * one offered first, what each commit says unless changed, and how it
294 * stands against its base.
295 */
296export type MergeChoicesView = {
297 methods: MergeMethod[];
298 defaultMethod: MergeMethod;
299 /**
300 * For `merge` and `squash`: the commit's title and message as the
301 * repository's choice makes them. Missing when the service could not say:
302 * the merge box then sends only what the merger writes.
303 */
304 messages: Partial<Record<MergeMethod, CommitText>>;
305 behind: boolean;
306 /** Fast-forward needs the base to be an ancestor of its head. */
307 canFastForward: boolean;
308 autoMergeAllowed: boolean;
309 /** Whether "Delete branch after merging" starts ticked. */
310 deleteBranchDefault: boolean;
311 /** Offer to update its branch whenever it is behind, not only when that is required. */
312 suggestUpdate: boolean;
313 /** Why a method cannot be used now, for those that cannot. */
314 unavailable?: Partial<Record<MergeMethod, string>>;
315 /** Its branch can be deleted once it merges. */
316 canDeleteBranch?: boolean;
317 /** Commits made merging it carry a `Signed-off-by:` trailer for the merger. */
318 signOff?: boolean;
319};
320
321/** The merge box's choices as the work service worked them out. */
322export function choicesFromService(choices: MergeChoices): MergeChoicesView {
323 const messages: Partial<Record<MergeMethod, CommitText>> = {};
324 const unavailable: Partial<Record<MergeMethod, string>> = {};
325 for (const option of choices.options) {
326 if (option.commitTitle != null) messages[option.method] = { title: option.commitTitle, message: option.commitMessage ?? "" };
327 if (option.unavailable) unavailable[option.method] = option.unavailable;
328 }
329 return {
330 methods: choices.methods,
331 defaultMethod: choices.defaultMethod ?? choices.methods[0] ?? "merge",
332 messages,
333 behind: choices.behind,
334 canFastForward: choices.canFastForward,
335 autoMergeAllowed: choices.autoMergeAllowed,
336 deleteBranchDefault: choices.deleteBranchDefault,
337 suggestUpdate: choices.suggestUpdate,
338 unavailable,
339 canDeleteBranch: choices.canDeleteBranch,
340 signOff: choices.signOff,
341 };
342}
343
344/**
345 * The merge box's choices worked out from the repository's settings, where
346 * the service does not say. No default messages: the page cannot read the
347 * pull request's commits, so what it made would differ from what the work
348 * service writes; the merge box leaves the message to the service instead.
349 */
350export function choicesFromSettings(
351 settings: RepoSettings,
352 behind: boolean,
353 ruleMethods: readonly MergeMethod[] = [],
354): MergeChoicesView {
355 const allowed = allowedMethods(settings).filter((method) => ruleMethods.length === 0 || ruleMethods.includes(method));
356 const methods = allowed.length > 0 ? allowed : allowedMethods(settings);
357 return {
358 methods,
359 defaultMethod: methods.includes(settings.defaultMergeMethod) ? settings.defaultMergeMethod : (methods[0] ?? "merge"),
360 messages: {},
361 behind,
362 canFastForward: !behind,
363 autoMergeAllowed: settings.allowAutoMerge,
364 deleteBranchDefault: settings.deleteBranchOnMerge,
365 suggestUpdate: settings.suggestUpdatingBranches,
366 };
367}