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.

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