Review context in the Merge dialog
This commit is contained in:
+133
-9
@@ -4,7 +4,7 @@ import { markdown } from "./markdown.js";
|
||||
import { previewURL } from "./preview.js";
|
||||
import { findingCount, reviewCounts } from "./reviews.js";
|
||||
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
import { fillTodoLinks } from "./todolink.js";
|
||||
|
||||
@@ -1171,15 +1171,139 @@ export async function confirmMerge(taskId: string): Promise<void> {
|
||||
void showAlert(errorText(err));
|
||||
return;
|
||||
}
|
||||
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));
|
||||
const title = mergeDrafts.get(taskId) ?? task.merge_title;
|
||||
mergeDrafts.delete(taskId);
|
||||
let input: HTMLInputElement | undefined;
|
||||
const edit = h("button", { type: "button", class: "merge-title-edit", title: "Edit the commit title", ariaLabel: "Edit the commit title" }, "✎");
|
||||
const row = h("div", { class: "merge-title" }, h("span", { class: "merge-title-text" }, title), edit);
|
||||
edit.onclick = () => {
|
||||
const field = h("input", { type: "text", value: title, required: true, maxLength: 72, pattern: ".*\\S.*", ariaLabel: "Commit title" });
|
||||
field.onkeydown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
row.closest("form")?.querySelector<HTMLButtonElement>(".modal-actions button:last-child")?.click();
|
||||
}
|
||||
};
|
||||
input = field;
|
||||
replace(row, field);
|
||||
field.focus();
|
||||
field.select();
|
||||
};
|
||||
const warnings = h("ul", { class: "merge-warnings" });
|
||||
const question: Node[] = [h("p", { class: "merge-into" }, `Merge into ${target} as:`), row, warnings];
|
||||
const co = coAuthorsSummary(coAuthors);
|
||||
if (co) {
|
||||
question.push(co);
|
||||
}
|
||||
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
|
||||
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: { merge_title: title.value } });
|
||||
});
|
||||
const leave = (go: () => void) => {
|
||||
mergeDrafts.set(taskId, input?.value ?? title);
|
||||
warnings.closest("dialog")?.close("cancel");
|
||||
go();
|
||||
};
|
||||
const watch = watchMergeWarnings(taskId, warnings, leave);
|
||||
await watch.ready;
|
||||
try {
|
||||
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
|
||||
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: { merge_title: input?.value ?? title } });
|
||||
});
|
||||
} finally {
|
||||
watch.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const mergeDrafts = new Map<string, string>();
|
||||
|
||||
function watchMergeWarnings(taskId: string, list: HTMLElement, leave: (go: () => void) => void): { ready: Promise<void>; dispose: () => void } {
|
||||
let reviewItems: HTMLElement[] = [];
|
||||
let target = 0;
|
||||
const render = () => {
|
||||
const questions = state.inbox.filter((i) => i.task_id === taskId && i.kind === "question").length;
|
||||
const items = [...reviewItems];
|
||||
if (questions > 0) {
|
||||
const text = questions === 1 ? "The agent's open question will be dismissed" : `The agent's ${questions} open questions will be dismissed`;
|
||||
items.push(mergeWarning(text, () => leave(() => focusTask(taskId, "inbox"))));
|
||||
}
|
||||
replace(list, ...items);
|
||||
list.hidden = items.length === 0;
|
||||
};
|
||||
const load = async () => {
|
||||
const started = ++target;
|
||||
let items: HTMLElement[];
|
||||
try {
|
||||
items = await reviewWarnings(taskId, leave);
|
||||
} catch (err) {
|
||||
items = [mergeWarning("Review status unavailable", undefined, errorText(err))];
|
||||
}
|
||||
if (started === target) {
|
||||
reviewItems = items;
|
||||
render();
|
||||
}
|
||||
};
|
||||
const offs = [
|
||||
on("event", (detail) => {
|
||||
const ev = detail as ByreEvent;
|
||||
if (ev.task_id === taskId && (ev.kind === "snapshot.created" || ev.kind.startsWith("review."))) {
|
||||
void load();
|
||||
}
|
||||
}),
|
||||
on("inbox", render),
|
||||
];
|
||||
return {
|
||||
ready: load(),
|
||||
dispose: () => {
|
||||
target++;
|
||||
for (const off of offs) {
|
||||
off();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function reviewWarnings(taskId: string, leave: (go: () => void) => void): Promise<HTMLElement[]> {
|
||||
const [snaps, reviews] = await Promise.all([get("/api/tasks/{taskId}/snapshots", { path: { taskId } }), get("/api/tasks/{taskId}/reviews", { path: { taskId } })]);
|
||||
const latest = snaps.at(-1);
|
||||
if (!latest) {
|
||||
return [mergeWarning("These changes haven't been reviewed")];
|
||||
}
|
||||
const items: HTMLElement[] = [];
|
||||
const open = () =>
|
||||
leave(() => {
|
||||
focusTask(taskId, "reviews");
|
||||
pickReview(latest.n);
|
||||
});
|
||||
const r = reviews.find((r) => r.snapshot_n === latest.n);
|
||||
if (!r) {
|
||||
items.push(mergeWarning("The latest snapshot hasn't been reviewed", open));
|
||||
} else if (r.state === "running") {
|
||||
items.push(mergeWarning("Review of the latest snapshot is still running", open));
|
||||
} else if (r.state === "failed") {
|
||||
items.push(mergeWarning("Review of the latest snapshot failed", open));
|
||||
} else if (findingCount(r) > 0) {
|
||||
items.push(mergeWarning(`Review of the latest snapshot found ${reviewCounts(r)}`, open));
|
||||
}
|
||||
const changes = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query: { since: latest.commit_sha } });
|
||||
if (changes.files.length > 0) {
|
||||
items.push(mergeWarning("Changes since the last snapshot haven't been reviewed"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function mergeWarning(text: string, open?: () => void, detail?: string): HTMLElement {
|
||||
if (!open) {
|
||||
return h("li", { class: "merge-warning", title: detail ?? "" }, `⚠ ${text}`);
|
||||
}
|
||||
const link = h("button", { type: "button", class: "merge-warning-link" }, text);
|
||||
link.onclick = open;
|
||||
return h("li", { class: "merge-warning" }, "⚠ ", link);
|
||||
}
|
||||
|
||||
function coAuthorsSummary(coAuthors: Participant[]): HTMLElement | null {
|
||||
if (coAuthors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const missing = coAuthors.filter((p) => !p.git_name || !p.git_email).length;
|
||||
const label = `${coAuthors.length} co-author${coAuthors.length === 1 ? "" : "s"}${missing > 0 ? ` (${missing} without git identity)` : ""}`;
|
||||
return h("details", { class: "merge-coauthors" }, h("summary", {}, label), h("p", { class: "modal-text" }, mergeTrailers(coAuthors)));
|
||||
}
|
||||
|
||||
function mergeTrailers(coAuthors: Participant[]): string {
|
||||
|
||||
Reference in New Issue
Block a user