| 1 | import type { JjChange, JjStack, VcsStatusResult } from "@t3tools/contracts"; |
| 2 | |
| 3 | export function supportsWorkspaceChanges( |
| 4 | status: Pick<VcsStatusResult, "isRepo" | "isJjWorkspace">, |
| 5 | ) { |
| 6 | return status.isRepo || status.isJjWorkspace === true; |
| 7 | } |
| 8 | |
| 9 | export const FOLLOW_UP_TITLE = "chore: follow-up changes"; |
| 10 | /** jj's root commit, the parent of a change with no parents. */ |
| 11 | const ROOT_COMMIT_ID = "0".repeat(40); |
| 12 | |
| 13 | export const changeTitle = (change: JjChange) => change.description.split("\n", 1)[0] ?? ""; |
| 14 | |
| 15 | /** The changes the panel lists, newest first: the stack without an empty working copy. */ |
| 16 | export function visibleChanges(stack: JjStack): JjChange[] { |
| 17 | return stack.changes.filter((change) => !(change.workingCopy && change.empty)); |
| 18 | } |
| 19 | |
| 20 | /** Inclusive index range into `visibleChanges`, newest first. */ |
| 21 | export type Selection = readonly [newest: number, oldest: number]; |
| 22 | |
| 23 | /** Click selects one change; shift- or cmd-click extends a contiguous range from the anchor. */ |
| 24 | export function selectChange(anchor: number, index: number, extend: boolean): Selection { |
| 25 | return extend ? [Math.min(anchor, index), Math.max(anchor, index)] : [index, index]; |
| 26 | } |
| 27 | |
| 28 | export function clampSelection(selection: Selection, count: number): Selection { |
| 29 | if (count === 0) return [0, 0]; |
| 30 | const newest = Math.min(selection[0], count - 1); |
| 31 | return [newest, Math.min(Math.max(selection[1], newest), count - 1)]; |
| 32 | } |
| 33 | |
| 34 | /** From the oldest selected change's first parent to the newest selected change. */ |
| 35 | export function selectionRange(changes: readonly JjChange[], selection: Selection) { |
| 36 | const newest = changes[selection[0]]; |
| 37 | const oldest = changes[selection[1]]; |
| 38 | if (!newest || !oldest) return null; |
| 39 | return { |
| 40 | fromCommitId: oldest.parentCommitIds[0] ?? ROOT_COMMIT_ID, |
| 41 | toCommitId: newest.commitId, |
| 42 | }; |
| 43 | } |
| 44 | |
| 45 | /** The PR a change ships in: the nearest change at or above it whose commit heads a PR. */ |
| 46 | export function pullRequestFor( |
| 47 | changes: readonly JjChange[], |
| 48 | prByHeadSha: ReadonlyMap<string, number>, |
| 49 | index: number, |
| 50 | ): number | null { |
| 51 | for (let i = Math.min(index, changes.length - 1); i >= 0; i--) { |
| 52 | const number = prByHeadSha.get(changes[i]!.commitId); |
| 53 | if (number !== undefined) return number; |
| 54 | } |
| 55 | return null; |
| 56 | } |
| 57 | |
| 58 | /** Index of the newest change heading a PR, or -1. Changes above it are not on the PR yet. */ |
| 59 | export function newestPullRequestIndex( |
| 60 | changes: readonly JjChange[], |
| 61 | prByHeadSha: ReadonlyMap<string, number>, |
| 62 | ): number { |
| 63 | return changes.findIndex((change) => prByHeadSha.has(change.commitId)); |
| 64 | } |
| 65 | |
| 66 | /** PR text: the oldest change that is not a follow-up, with every change's Assisted-by trailers. */ |
| 67 | export function pullRequestText(changes: readonly JjChange[]) { |
| 68 | const base = |
| 69 | changes.findLast((change) => changeTitle(change) && changeTitle(change) !== FOLLOW_UP_TITLE) ?? |
| 70 | changes[0]; |
| 71 | const models = [ |
| 72 | ...new Set( |
| 73 | changes.flatMap((change) => |
| 74 | change.trailers.filter((t) => t.key === "Assisted-by").map((t) => t.value), |
| 75 | ), |
| 76 | ), |
| 77 | ]; |
| 78 | const [title = "", ...rest] = (base?.description ?? "").split("\n"); |
| 79 | const prose = rest |
| 80 | .join("\n") |
| 81 | .replace(/(^|\n)Assisted-by: .+/g, "") |
| 82 | .trim(); |
| 83 | const credits = models.map((model) => `Assisted-by: ${model}`).join("\n"); |
| 84 | return { |
| 85 | title, |
| 86 | body: [prose, credits].filter(Boolean).join("\n\n"), |
| 87 | baseChangeId: base?.changeId, |
| 88 | }; |
| 89 | } |