1import type { JjChange, JjStack, VcsStatusResult } from "@t3tools/contracts";
2
3export function supportsWorkspaceChanges(
4 status: Pick<VcsStatusResult, "isRepo" | "isJjWorkspace">,
5) {
6 return status.isRepo || status.isJjWorkspace === true;
7}
8
9export const FOLLOW_UP_TITLE = "chore: follow-up changes";
10/** jj's root commit, the parent of a change with no parents. */
11const ROOT_COMMIT_ID = "0".repeat(40);
12
13export 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. */
16export 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. */
21export type Selection = readonly [newest: number, oldest: number];
22
23/** Click selects one change; shift- or cmd-click extends a contiguous range from the anchor. */
24export function selectChange(anchor: number, index: number, extend: boolean): Selection {
25 return extend ? [Math.min(anchor, index), Math.max(anchor, index)] : [index, index];
26}
27
28export 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. */
35export 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. */
46export 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. */
59export 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. */
67export 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}