| 1 | import type { CommitMessageDefault, MergeChoices, MergeMethod, RepoSettings } from "@g1t/contracts"; |
| 2 | |
| 3 | /** Every way a pull request can land, in the order they are offered. */ |
| 4 | export 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. */ |
| 7 | export 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. */ |
| 47 | export 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. */ |
| 52 | export 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. */ |
| 57 | export 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. */ |
| 62 | export 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. */ |
| 79 | export 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. */ |
| 93 | export 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 | */ |
| 99 | export 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. */ |
| 120 | export 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. */ |
| 138 | export 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). */ |
| 141 | export 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. */ |
| 148 | function subject(message: string): string { |
| 149 | return (message.split("\n")[0] ?? "").trim(); |
| 150 | } |
| 151 | |
| 152 | /** `* subject` for each commit, one per line. */ |
| 153 | function 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. */ |
| 162 | function 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 | */ |
| 176 | function 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. */ |
| 196 | export 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. */ |
| 214 | function 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 | */ |
| 224 | export 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 | |
| 269 | const REMEMBERED = "g1t:merge-method:"; |
| 270 | |
| 271 | /** The method this viewer last merged with in a repository, if they did and it is still allowed. */ |
| 272 | export 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. */ |
| 282 | export 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 | */ |
| 296 | export 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. */ |
| 322 | export 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 | */ |
| 350 | export 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 | } |