Edit the drafted merge message before confirming

This commit is contained in:
Ian Gulliver
2026-10-15 18:55:20 -07:00
parent de394b8c35
commit 5a07658523
16 changed files with 324 additions and 211 deletions
+9 -2
View File
@@ -263,7 +263,7 @@ export interface paths {
};
get?: never;
put?: never;
/** @description Starts the same agent turn as merge-in. When that turn ends normally, byre squashes the task's worktree into one commit titled with the task's merge_title on the project's default branch, authored by the caller with a Co-authored-by trailer for each of the task's co-authors, pushes it to origin, fast-forwards the project's checkout to it, then removes the worktree and branch and marks the task merged. While another task in the project is merging, the merge is held instead (merge_queued_at is set) and starts when no merge is in progress in the project and the task's session is idle, oldest first. Refused while a turn is running, or the merge is already held. */
/** @description Sets the task's merge_title to the confirmed title, then starts the same agent turn as merge-in, without its invitation to revise the title. When that turn ends normally, byre squashes the task's worktree into one commit titled with the task's merge_title on the project's default branch, authored by the caller with a Co-authored-by trailer for each of the task's co-authors, pushes it to origin, fast-forwards the project's checkout to it, then removes the worktree and branch and marks the task merged. While another task in the project is merging, the merge is held instead (merge_queued_at is set) and starts when no merge is in progress in the project and the task's session is idle, oldest first. The title is fixed from here until the task closes. Refused while a turn is running, or the merge is already held. */
post: operations["mergeTask"];
delete?: never;
options?: never;
@@ -1640,7 +1640,14 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: {
content: {
"application/json": {
/** @description The squash commit's title: one line, at most 72 characters. */
merge_title: string;
};
};
};
responses: {
/** @description The merge was started or held. */
202: {
+20 -5
View File
@@ -74,9 +74,18 @@ export function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function modal(text: string, buttons: { label: string; value: string; class?: string }[], anchor: Element | null = null): Promise<string> {
const actions = buttons.map((b) => h("button", { type: "submit", value: b.value, class: b.class ?? "" }, b.label));
const dialog = h("dialog", { class: "modal" }, h("form", { method: "dialog" }, h("p", { class: "modal-text" }, text), h("div", { class: "modal-actions" }, ...actions)));
function modal(content: string | Node[], buttons: { label: string; value: string; class?: string }[], anchor: Element | null = null): Promise<string> {
const actions = buttons.map((b) => h("button", { type: "submit", value: b.value, class: b.class ?? "", formNoValidate: b.value === "cancel" }, b.label));
const body = typeof content === "string" ? [h("p", { class: "modal-text" }, content)] : content;
const dialog = h("dialog", { class: "modal" }, h("form", { method: "dialog" }, ...body, h("div", { class: "modal-actions" }, ...actions)));
for (const input of dialog.querySelectorAll("input")) {
input.onkeydown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
actions.at(-1)?.click();
}
};
}
if (anchor) {
const rect = anchor.getBoundingClientRect();
dialog.classList.add("anchored");
@@ -90,6 +99,12 @@ function modal(text: string, buttons: { label: string; value: string; class?: st
resolve(dialog.returnValue);
};
dialog.showModal();
const input = dialog.querySelector("input");
if (input) {
input.focus();
input.select();
return;
}
actions.at(-1)?.focus();
});
}
@@ -120,9 +135,9 @@ export async function showAlert(text: string): Promise<void> {
await modal(text, [{ label: "OK", value: "ok", class: "primary" }]);
}
export async function showConfirm(text: string, action: string, danger = false, anchor: Element | null = null): Promise<boolean> {
export async function showConfirm(content: string | Node[], action: string, danger = false, anchor: Element | null = null): Promise<boolean> {
const value = await modal(
text,
content,
[
{ label: "Cancel", value: "cancel" },
{ label: action, value: "ok", class: danger ? "danger-primary" : "primary" },
+16 -7
View File
@@ -1133,25 +1133,34 @@ export async function confirmMerge(taskId: string): Promise<void> {
void showAlert(errorText(err));
return;
}
await confirmThen(mergeQuestion(task.merge_title, target, coAuthors), "Merge", false, document.querySelector(".merge-button"), async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId } });
const title = h("input", { type: "text", value: task.merge_title, required: true, maxLength: 72, pattern: ".*\\S.*", class: "modal-field", ariaLabel: "Commit title" });
const question = [h("p", { class: "modal-text" }, `Merge into ${target} as one commit titled:`), title];
const trailers = mergeTrailers(coAuthors);
if (trailers) {
question.push(h("p", { class: "modal-text" }, trailers));
}
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: { merge_title: title.value } });
});
}
function mergeQuestion(title: string, target: string, coAuthors: Participant[]): string {
const lines = [`Merge "${title}" into ${target}?`];
function mergeTrailers(coAuthors: Participant[]): string {
const lines: string[] = [];
const credited = coAuthors.filter((p) => p.git_name && p.git_email);
const missing = coAuthors.filter((p) => !p.git_name || !p.git_email);
if (credited.length > 0) {
lines.push("", "Co-authored-by:", ...credited.map((p) => ` ${p.git_name} <${p.git_email}>`));
lines.push("Co-authored-by:", ...credited.map((p) => ` ${p.git_name} <${p.git_email}>`));
}
if (missing.length > 0) {
lines.push("", "No git identity, so no trailer:", ...missing.map((p) => ` ${p.display_name} (${p.login})`));
if (lines.length > 0) {
lines.push("");
}
lines.push("No git identity, so no trailer:", ...missing.map((p) => ` ${p.display_name} (${p.login})`));
}
return lines.join("\n");
}
async function confirmThen(question: string, action: string, danger: boolean, anchor: Element | null, act: () => Promise<void>) {
async function confirmThen(question: string | Node[], action: string, danger: boolean, anchor: Element | null, act: () => Promise<void>) {
if (!(await showConfirm(question, action, danger, anchor))) {
return;
}