From eef0cbbaa8ae3fdf1dfde480e6cea8af50e3e37a Mon Sep 17 00:00:00 2001 From: kaceper11 <22818077+kaceper11@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:36:48 +0200 Subject: [PATCH 1/3] fix(tests): write migration output back to the file that was read load_settings_in and load_projects_in resolve the record file for the read, then re-resolve it through global_dir() for the write-back. TERMIC_DATA_DIR is process-global and tests run in threads, so a with_scratch_data_dir window can flip it between the two resolutions: the migrated write lands in a settings.json/projects.json the load never read from. That clobbers the scratch a neighbouring test just wrote (the intermittent agent_hooks::...status_line failure), and flipped the other way drops test content into the developer's real data dir. Keep the already-resolved path for the write-back; the callers' doc comments already promise the write lands where the records came from. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src-tauri/src/lib.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b611c304..2dd4c6ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1529,7 +1529,11 @@ fn load_projects_in(id: &ProfileId) -> Vec { p.profile = id.clone(); } if dirty { - let _ = save_projects_in(id, &list); + // Write back to `f` — the file just read — rather than re-resolving + // it. `TERMIC_DATA_DIR` is process-global and a test's scratch window + // can flip it between the two resolutions, landing this write in a + // directory the records were never read from. + let _ = save_projects_at(&f, &list); } list } @@ -1760,8 +1764,12 @@ fn project_path_taken(list: &[Project], profile: &ProfileId, canon: &str) -> boo } fn save_projects_in(id: &ProfileId, list: &[Project]) -> Result<()> { + save_projects_at(&projects_file_in(id)?, list) +} + +fn save_projects_at(f: &Path, list: &[Project]) -> Result<()> { let json = serde_json::to_string_pretty(list)?; - write_atomic(&projects_file_in(id)?, json.as_bytes())?; + write_atomic(f, json.as_bytes())?; Ok(()) } @@ -22177,7 +22185,14 @@ pub(crate) fn load_settings_in(id: &ProfileId) -> Settings { // had its settings written over the root profile on every load (the // root's accounts and paths went with them), and never received the // migration itself, so the next load did it again. - let _ = save_settings_in(id, &s); + // + // `f`, not a re-resolution through `save_settings_in`: `TERMIC_DATA_DIR` + // is process-global, and a test's scratch window can flip it between the + // read and this write — re-resolving would land the migrated copy in a + // settings.json the load never read from (clobbering that scratch's + // file — observed as an intermittent `agent_hooks` test failure — or, + // flipped the other way, in the developer's real data dir). + let _ = save_settings_at(&f, &s); } s } @@ -22667,8 +22682,12 @@ fn settings_save(app: AppHandle, window: tauri::Window, s: Settings) -> Result<( /// Settings UI does, instead of growing a second encoder that could drift. pub(crate) fn save_settings_in(id: &ProfileId, s: &Settings) -> Result<(), String> { let f = settings_file_in(id).map_err(|e| e.to_string())?; + save_settings_at(&f, s) +} + +fn save_settings_at(f: &Path, s: &Settings) -> Result<(), String> { let json = serde_json::to_string_pretty(s).map_err(|e| e.to_string())?; - write_atomic(&f, json.as_bytes()).map_err(|e| e.to_string()) + write_atomic(f, json.as_bytes()).map_err(|e| e.to_string()) } /// The ROOT profile's settings writer. Mirror of [`load_settings_inner`]. From cfd95ce3bea6609bc66b365db640c2c7794ec4a1 Mon Sep 17 00:00:00 2001 From: kaceper11 <22818077+kaceper11@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:46:11 +0200 Subject: [PATCH 2/3] feat(delivery): add Delivery panel for agent handoffs New Delivery tab in the right panel: select repos, inspect CI/review evidence per repo, prepare scoped agent requests (fix / replies / PR descriptions / conflict resolution), queue or send to a running agent tab, and import the agent's report back into draft cards. - Durable requests in delivery.json with scope summary, status lifecycle (prepared/queued/sent/drafted/failed/uncertain), reply drafts and PR text proposals; orphan queued requests reconcile against live queues. - Create PRs dialog drafts titles/bodies per repo or all at once, repeatable, auto-filling only pristine fields; reuses open PRs. - Per-item actions: fix failed CI checks and unresolved review threads individually or in bulk; draft replies per thread. - Board: Needs action filter, delivery chips on cards/members that jump to the task's Delivery tab. - Fix WKWebView disabled-trigger bug: DropdownTrigger/PopoverTrigger mirror child's disabled onto the Radix trigger (pointerdown opens disabled menus in WebKit). - Fix CiTree summary-wrapping bug: interactive controls inside toggled the disclosure; explicit open state instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/data-model.md | 11 + docs/ipc.md | 27 + docs/ui.md | 21 + e2e/helpers.ts | 3 +- e2e/specs/agent.e2e.ts | 3 + e2e/specs/board.e2e.ts | 25 + e2e/specs/delivery.e2e.ts | 123 +++ e2e/specs/settings.e2e.ts | 2 + src-tauri/src/delivery.rs | 1333 +++++++++++++++++++++++++ src-tauri/src/forge.rs | 87 +- src-tauri/src/forge_delivery.rs | 1070 ++++++++++++++++++++ src-tauri/src/lib.rs | 132 ++- src/components/task/DeliveryPanel.tsx | 557 +++++++++++ src/components/task/PrCard.tsx | 10 +- src/components/task/RightPanel.tsx | 14 +- src/components/task/TerminalPane.tsx | 23 +- src/components/ui/Dropdown.tsx | 14 +- src/components/ui/Popover.tsx | 12 +- src/components/views/BoardView.tsx | 53 +- src/lib/delivery.test.ts | 26 + src/lib/delivery.ts | 37 + src/lib/deliverySend.test.ts | 74 ++ src/lib/deliverySend.ts | 64 ++ src/lib/ipc.ts | 16 + src/lib/types.ts | 90 ++ src/locales/en/chrome.ts | 6 +- src/locales/en/panels.ts | 92 ++ src/locales/en/task.ts | 1 + src/locales/zh-CN/chrome.ts | 6 +- src/locales/zh-CN/panels.ts | 92 ++ src/locales/zh-CN/task.ts | 1 + src/main.tsx | 4 +- src/store/delivery.ts | 51 + src/store/pr.test.ts | 34 + src/store/pr.ts | 46 +- src/store/ui.ts | 4 + 36 files changed, 4099 insertions(+), 65 deletions(-) create mode 100644 e2e/specs/delivery.e2e.ts create mode 100644 src-tauri/src/delivery.rs create mode 100644 src-tauri/src/forge_delivery.rs create mode 100644 src/components/task/DeliveryPanel.tsx create mode 100644 src/lib/delivery.test.ts create mode 100644 src/lib/delivery.ts create mode 100644 src/lib/deliverySend.test.ts create mode 100644 src/lib/deliverySend.ts create mode 100644 src/store/delivery.ts diff --git a/docs/data-model.md b/docs/data-model.md index d8746912..55c10226 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -64,3 +64,14 @@ Only the durable default tab can inherit legacy task-level resume history. A new secondary tab starts a fresh session even if it is the first tab of a different profile. Explicit `tab --resume SESSION_ID` and restoration of a tab's own captured session continue to resume that specific session. +## Delivery metadata + +Composition members optionally persist `pr_url`, `pr_number`, and `pr_provider`, +matching the host task's identity fields. Older records default them to absent; +live provider state remains in the PR store. + +Profile-scoped `scratch//delivery.json` owns reviewed requests, saved +reply drafts, imported PR descriptions, and per-repository bulk results. Agents +write `.termic-delivery/.json` reports in the task checkout, excluded +through Git's common `info/exclude` for both repositories and linked worktrees. +Reports are size-bounded and checked against the reviewed request scope. diff --git a/docs/ipc.md b/docs/ipc.md index aed61e8d..a52dd97e 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -237,3 +237,30 @@ or silently choose a reasoning level. A model-switch prompt sent after creating a tab cannot make the original launch fresh and is not an equivalent substitute for launch arguments. +## Delivery commands + +`task_delivery_repos`, `task_delivery_details`, and `task_delivery_log` provide +local inventory and on-demand provider evidence. `task_delivery_validate` binds +actions to recorded selectors, canonical paths, branches, HEADs, remotes, +working-tree fingerprints, and optional PR source revisions. All provider/Git +work runs on `spawn_blocking`. + +`task_delivery_request { id, expected, drafts, kind }` stores a reviewed request; +kind is `fix`, `replies`, `prs`, or `conflicts`. Agents write bounded JSON reports +into the ignored task `.termic-delivery` directory. `task_delivery_requests` +imports only requested thread keys/repositories; a malformed report records +its error on that one request instead of failing the listing, and a corrected +rewrite imports on the next read. Importing creates no PRs and posts no +comments. `task_delivery_request_status` tracks handoff outcomes through a +checked transition table: `prepared → queued/sent/failed/uncertain`, +`queued|sent → sent/failed/uncertain`, `uncertain → sent/failed`, +`failed → queued/sent`, same-status writes are no-ops, and `drafted` is +terminal. +`task_delivery_draft_save` edits a local reply; `task_delivery_reply_post` posts +one explicitly selected reply to its original thread. A stable hidden comment +marker identifies uncertain posts during readback. + +`task_delivery_pr_create` and `task_delivery_update` process selected repositories +sequentially and retain per-repository results across retries and restarts. +`task_delivery_results` restores those results. `task_delivery_archive_ready` +keeps tasks open when a repository is missing, dirty, unknown, or undelivered. diff --git a/docs/ui.md b/docs/ui.md index f05624a0..767f037b 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1500,3 +1500,24 @@ reports this" and "not supported yet" only mean something next to each other, and it is a decision made once, not a per-agent preference. And the link out of Notifications does not gate on which agents are supported; the table is the authority on that. +## Delivery workflow + +The right panel's Delivery tab lists every recorded checkout and separates PR, +CI, and review state from agent activity. Provider details and failed-job log +excerpts load on demand. Select repositories and evidence, then review the exact +agent, checkout paths, branches, and revisions before sending or queuing. A +changed destination stops the handoff. Evidence is untrusted data; the prompt +does not authorize commits, pushes, posting replies, or merging. + +Reply drafts require Save locally or Post this reply. Saved drafts survive +restarts; unsaved edits survive tab navigation within the session. Uncertain +posting outcomes use readback rather than automatic retries. A missing reply +requires a separate explicit retry, with fresh readback before posting again. Drafting PR text +does not create PRs. Bulk creation defaults to draft PRs, reuses open PRs, and +shows each repository's result. Branch updates show autostash behavior and retain +conflict results for manual or reviewed agent resolution. + +The Board's persistent Needs action toggle filters delivery blockers without +moving cards between agent-activity columns. Compact links open Delivery. +Multi-repository auto-archive accounts for every checkout rather than relying +on the host PR merge alone. The existing opt-in comment watcher stays separate. diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 1370e6fa..6a283570 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -71,6 +71,7 @@ export interface TermicApi { useRace: { getState: () => any }; /** PR/MR store (src/store/pr.ts). Specs seed `byTask` directly to render * card states without a real forge/network. */ + useDelivery: { getState: () => any; setState: (p: any) => void }; usePr: { getState: () => any; setState: (p: any) => void }; /** Per-task change summaries (src/store/diffStat.ts). Demand-driven with a * staleness floor, so a spec that changes a worktree calls `invalidate` @@ -303,7 +304,7 @@ export async function waitForAppShell(timeout = 30_000): Promise { * clickable element with text: Git" whenever an earlier spec left a file * behind, and passed when git.e2e ran alone. */ -export async function openRightTab(label: "All files" | "Git"): Promise { +export async function openRightTab(label: "All files" | "Git" | "Delivery"): Promise { await browser.execute((l) => { const el = document.querySelector( `[data-testid="right-tab"][data-tab="${l}"]`, diff --git a/e2e/specs/agent.e2e.ts b/e2e/specs/agent.e2e.ts index ba249ab9..835e1d88 100644 --- a/e2e/specs/agent.e2e.ts +++ b/e2e/specs/agent.e2e.ts @@ -1084,6 +1084,9 @@ describe("pending work defers done", () => { await waitForAppShell(); await requireTermicApi(); await requireWorkBadges(); + // A previously interrupted ceiling test can leave its 8s override behind. + // This case exercises the normal hold, so establish the default before spawn. + await browser.execute(() => localStorage.removeItem("workDoneCeilingMs")); taskId = await openTask("e2e-pending-work"); await waitForAgentReady(taskId); diff --git a/e2e/specs/board.e2e.ts b/e2e/specs/board.e2e.ts index 98c3fbd0..660f7e32 100644 --- a/e2e/specs/board.e2e.ts +++ b/e2e/specs/board.e2e.ts @@ -455,6 +455,31 @@ describe("board view", () => { await snap("board-card-pr.png"); }); + it("opens Delivery from the actionable CI status without a generic Delivery row", async () => { + await browser.execute(id => { + const store = window.__termic!.usePr; + store.setState({ byTask: { ...store.getState().byTask, [id]: { + lookup: { status: "ok", provider: "github", pr: { provider: "github", number: 42, + url: "https://example.test/pull/42", state: "open", checks: "failing", review: "changes_requested", + title: "Review fixture", base: "main", head: "topic" } }, + loading: false, fetchedAt: Date.now(), + } } }); + const app = window.__termic!.useApp.getState(); + if (!app.rightPanelHidden) app.toggleRightPanel(); + }, t2); + const selector = CARD(t2) + ' [data-testid="board-card-delivery"]'; + await waitVisible(selector); + const text = await browser.execute(sel => document.querySelector(sel)?.textContent ?? "", selector); + expect(text).toContain("CI"); + expect(text).not.toContain("Delivery"); + await browser.execute(sel => (document.querySelector(sel) as HTMLElement).click(), selector); + await waitVisible('[data-testid="delivery-panel"]'); + // Put the suite back on its Board and keep later editor suites on Files. + await browser.execute(() => (document.querySelector('[data-testid="right-tab"][data-tab="All files"]') as HTMLElement).click()); + await clickByText("Kanban"); + await waitVisible('[data-testid="board-view"]'); + }); + it("a wide PR chip and churn never make the column scroll sideways", async () => { // Shipped broken in 1.11.2: the chip and the churn were both shrink-0 on // one line, so "#18495 - checks failing" next to "+356 -21 12 files" diff --git a/e2e/specs/delivery.e2e.ts b/e2e/specs/delivery.e2e.ts new file mode 100644 index 00000000..e0c8af22 --- /dev/null +++ b/e2e/specs/delivery.e2e.ts @@ -0,0 +1,123 @@ +import { writeFileSync } from "node:fs"; +import { archiveTask, clickByText, clickWhenVisible, clickMenuItem, createWorktreeTask, openRightTab, requireTermicApi, waitGone, waitVisible, waitForAgentReady, waitForAppShell, waitForText } from "../helpers"; + +const openActions = async () => { + await browser.waitUntil(() => browser.execute(() => !document.querySelector('[data-testid="delivery-actions"]')?.disabled)); + await browser.execute(() => { + const trigger = document.querySelector('[data-testid="delivery-actions"]')!; + const init = { bubbles: true, cancelable: true, button: 0, pointerType: "mouse", isPrimary: true }; + trigger.dispatchEvent(new PointerEvent("pointerdown", init)); + trigger.dispatchEvent(new PointerEvent("pointerup", init)); + }); + await waitVisible('[role="menu"]'); +}; +const refreshPanel = async () => { + const selector = '[data-testid="delivery-panel"] button[aria-label="Refresh"]'; + await browser.waitUntil(() => browser.execute(s => !document.querySelector(s)?.disabled, selector)); + await clickWhenVisible(selector); +}; +describe("task delivery", () => { + let taskId = ""; + after(async () => { await browser.keys("Escape"); await openRightTab("All files"); if (taskId) await archiveTask(taskId); }); + + it("shows clean repositories and retains failed branch-update results", async () => { + await waitForAppShell(); + await requireTermicApi(); + taskId = await createWorktreeTask("e2e-delivery", "e2e-delivery"); + await openRightTab("Delivery"); + await waitForText("Select repositories"); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[data-testid="delivery-repo"]')?.textContent?.includes("Clean"))); + await clickWhenVisible('[data-testid="delivery-repo"] input[type="checkbox"]'); + await openActions(); + await clickMenuItem("Update branches"); + await browser.execute(() => { const select = document.querySelector('[role="dialog"] select')!; select.value = "pull"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await clickByText("Update selected branches"); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[data-testid="delivery-results"]')?.textContent?.toLowerCase().includes("upstream"))); + await openRightTab("All files"); + await openRightTab("Delivery"); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[data-testid="delivery-results"]')?.textContent?.toLowerCase().includes("upstream"))); + }); + + it("reviews exact scope and rejects a changed worktree before sending", async () => { + await waitForAgentReady(taskId); + // Panel selection is local and was intentionally reset by leaving it. + await clickWhenVisible('[data-testid="delivery-repo"] input[type="checkbox"]'); + await openActions(); + await clickMenuItem("Create PRs"); + await clickByText("Draft all with agent"); + await waitForText("Review agent handoff"); + await waitForText("does not authorize commits"); + await browser.execute(async id => { + await window.__termic!.ipc.taskFileWrite(id, "README.md", "changed after reviewing the handoff\n"); + }, taskId); + await browser.waitUntil(() => browser.execute(() => !document.querySelector('[data-testid="delivery-send"]')?.disabled)); + await clickWhenVisible('[data-testid="delivery-send"]'); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[role="dialog"]')?.textContent?.includes("working files changed"))); + await browser.keys("Escape"); + await browser.keys("Escape"); + await waitGone('[role="dialog"]'); + await browser.execute(async id => { + // Undo through git in the fixture's isolated checkout, not the shared repo. + const t = window.__termic!; + await t.ipc.taskDiscard(id, "", ["README.md"]); + }, taskId); + }); + + it("imports an agent PR report and auto-fills untouched dialog fields", async () => { + await refreshPanel(); + await browser.waitUntil(() => browser.execute(id => !window.__termic!.useDelivery.getState().byTask[id]?.loading, taskId)); + await openActions(); + await clickMenuItem("Create PRs"); + await clickByText("Draft all with agent"); + await waitForText("Review agent handoff"); + const request = await browser.execute(async id => { + const requests = await window.__termic!.ipc.taskDeliveryRequests(id); + return requests[requests.length - 1]; + }, taskId); + // Simulate an agent writing its report, using the reviewed fixture path. + writeFileSync(request.report, JSON.stringify({ drafts: [], prs: [ + { dir_name: "", title: "Proposed delivery title", body: "Proposed description, pending human review." }, + ] })); + await browser.execute(async (id, requestId) => { + await window.__termic!.ipc.taskDeliveryRequestStatus(id, requestId, "sent"); + }, taskId, request.id); + await browser.waitUntil(() => browser.execute(() => !document.querySelector('[data-testid="delivery-send"]')?.disabled)); + await browser.keys("Escape"); + // The imported draft lands in the still-open Create PRs dialog, filling + // only fields the user has not touched. + await browser.execute(async id => { await window.__termic!.useDelivery.getState().refresh(id); }, taskId); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[role="dialog"] input')?.value === "Proposed delivery title")); + expect(await browser.execute(() => document.querySelector('[role="dialog"] input[type="checkbox"]')?.checked)).toBe(true); + await waitForText("pushes committed branch changes"); + await browser.keys("Escape"); + await waitGone('[role="dialog"]'); + await waitForText("Proposed delivery title"); + }); + + it("renders independent review and CI states without treating skipped jobs as passed", async () => { + await browser.execute(id => { + const store = window.__termic!.useDelivery; + const current = store.getState().byTask[id]; + const identity = current.repos[0].identity; + window.__termic!.usePr.setState({ byTask: { ...window.__termic!.usePr.getState().byTask, [id]: { + lookup: { status: "ok", provider: "github", pr: { provider: "github", number: 7, state: "open", checks: "failing", review: "changes_requested", title: "Handle missing branch", url: "https://example.test/pull/7" } }, + loading: false, fetchedAt: Date.now(), + } } }); + store.setState({ byTask: { ...store.getState().byTask, [id]: { ...current, details: { + "": { identity, revision: "abc123", pr: { provider: "github", number: 7, url: "https://example.test/pull/7" }, + ci_error: null, threads_error: null, + ci: [{ id: "run", parent: null, name: "Build workflow", status: "failed", duration: null, url: "", log_id: null }, + { id: "job", parent: "run", name: "Skipped deployment", status: "skipped", duration: null, url: "", log_id: null }], + threads: [{ id: "thread", reply_id: "1", path: "src/example.ts", line: 12, resolved: false, url: "", + comments: [{ id: "1", author: "Reviewer", body: "Please handle the missing branch." }] }], + }, + } } } }); + }, taskId); + await clickWhenVisible('[data-testid="delivery-repo-details"]'); + await waitForText("Build workflow"); + await waitForText("Skipped deployment"); + await waitForText("skipped"); + await waitForText("src/example.ts:12 · Unresolved"); + await waitForText("Please handle the missing branch."); + }); +}); diff --git a/e2e/specs/settings.e2e.ts b/e2e/specs/settings.e2e.ts index 133b59ca..a69fe122 100644 --- a/e2e/specs/settings.e2e.ts +++ b/e2e/specs/settings.e2e.ts @@ -3369,6 +3369,8 @@ describe("agent hooks", () => { "theme_mode": "dark" } `; + // A failed prior run can retain its original-config backup. + rmTree(devinDir, { bestEffort: true }); mkdirSync(devinDir, { recursive: true }); writeFileSync(devinConfig, userDevinConfig); diff --git a/src-tauri/src/delivery.rs b/src-tauri/src/delivery.rs new file mode 100644 index 00000000..2747f325 --- /dev/null +++ b/src-tauri/src/delivery.rs @@ -0,0 +1,1333 @@ +//! Task-scoped delivery. Public commands accept recorded repo selectors, never arbitrary paths. +use crate::*; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Identity { + pub dir_name: String, + pub path: String, + pub branch: String, + pub head: String, + pub remote: String, + pub worktree: String, + #[serde(default)] + pub pr_number: Option, + #[serde(default)] + pub pr_revision: Option, +} +#[derive(Serialize)] +pub struct Repo { + pub dir_name: String, + pub name: String, + pub mode: String, + pub base: String, + pub dirty: bool, + pub changed: Option, + pub identity: Option, + pub error: Option, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct CiNode { + pub id: String, + pub parent: Option, + pub name: String, + pub kind: String, + pub status: String, + pub duration: Option, + pub url: String, + pub log_id: Option, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct ThreadComment { + pub id: String, + pub author: String, + pub body: String, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct ReviewThread { + pub id: String, + pub reply_id: String, + pub path: Option, + pub line: Option, + pub resolved: Option, + pub url: String, + pub comments: Vec, +} +#[derive(Serialize)] +pub struct Details { + pub identity: Identity, + pub pr: forge::PrStatus, + pub revision: String, + pub ci: Vec, + pub ci_error: Option, + pub threads: Vec, + pub threads_error: Option, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct Draft { + pub key: String, + pub dir_name: String, + pub pr_number: u64, + pub thread_id: String, + pub reply_id: String, + pub body: String, + pub status: String, + pub error: Option, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct PrText { + pub dir_name: String, + pub title: String, + pub body: String, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct Request { + pub id: String, + pub identities: Vec, + pub report: String, + pub status: String, + pub error: Option, + #[serde(default)] + pub kind: String, + /// Human-readable "what this request covers" — repo names plus the + /// evidence items (check names, thread paths) selected at send time. + /// Display-only; evidence itself is embedded in the prompt text. + #[serde(default)] + pub scope: String, + pub drafts: Vec, + pub prs: Vec, +} +#[derive(Default, Serialize, Deserialize)] +struct Saved { + requests: Vec, + #[serde(default)] + results: Vec, +} +#[derive(Deserialize)] +pub struct PrInput { + pub identity: Identity, + pub title: String, + pub body: String, + pub base: String, + pub draft: bool, +} +#[derive(Serialize, Deserialize)] +pub struct ActionResult { + pub dir_name: String, + pub name: String, + pub url: Option, + pub result: Option, + pub error: Option, +} +// ponytail: serialize delivery mutations, use per-task locks if bulk throughput matters. +static LOCK: Mutex<()> = Mutex::new(()); + +fn task(id: &str) -> Result { + load_tasks_all() + .into_iter() + .find(|w| w.id == id && !w.archived) + .ok_or_else(|| "Task is missing or archived".into()) +} +fn fingerprint(cwd: &Path) -> Result { + let status = git(&["status", "--porcelain", "-uall"], cwd).map_err(|e| e.to_string())?; + let diff = git(&["diff", "--binary", "HEAD"], cwd).map_err(|e| e.to_string())?; + let mut hash = Sha256::new(); + // `.termic-delivery` is report scratch: it appears as untracked until the + // request that created it writes its info/exclude line, and must not move + // the fingerprint underneath the identities captured in between. + hash.update( + status + .lines() + .filter(|l| !(l.starts_with("?? ") && l[3..].starts_with(".termic-delivery"))) + .collect::>() + .join("\n"), + ); + hash.update(diff.as_bytes()); + // ponytail: untracked metadata detects ordinary edits; hash contents if same-size/same-mtime edits become a real gap. + for p in git(&["ls-files", "--others", "--exclude-standard", "-z"], cwd) + .map_err(|e| e.to_string())? + .split('\0') + .filter(|p| !p.is_empty() && !p.starts_with(".termic-delivery")) + { + if let Ok(m) = fs::symlink_metadata(cwd.join(p)) { + hash.update(m.len().to_le_bytes()); + hash.update(format!("{:?}", m.modified()).as_bytes()); + } + } + Ok(format!("{:x}", hash.finalize())) +} +fn identity(w: &Task, dir: &str) -> Result { + let cwd = repo_cwd(w, dir)?; + let path = dunce::canonicalize(&cwd).map_err(|e| format!("Checkout missing: {e}"))?; + let branch = git(&["branch", "--show-current"], &path) + .map_err(|e| e.to_string())? + .trim() + .to_string(); + let head = git(&["rev-parse", "HEAD"], &path) + .map_err(|e| e.to_string())? + .trim() + .to_string(); + let remote = git(&["remote", "get-url", &detect_default_remote(&path)], &path) + .map(|s| forge::remote_for_display(s.trim())) + .unwrap_or_default(); + Ok(Identity { + dir_name: dir.into(), + path: path.to_string_lossy().into_owned(), + branch, + head, + remote, + worktree: fingerprint(&path)?, + pr_number: None, + pr_revision: None, + }) +} +fn validate(id: &str, expected: &Identity) -> Result<(Task, PathBuf), String> { + let w = task(id)?; + let mut current = identity(&w, &expected.dir_name)?; + if let Some(number) = expected.pr_number { + let pr = pr_for(&w, Path::new(¤t.path), &expected.dir_name)?; + if pr.number != number { + return Err("PR changed. Review again.".into()); + } + current.pr_number = Some(number); + current.pr_revision = Some(forge::delivery::revision( + &pr.provider, + Path::new(¤t.path), + number, + )?); + } + if ¤t != expected { + return Err( + "Repository, branch, revision, or working files changed. Refresh and review again." + .into(), + ); + } + Ok((w, PathBuf::from(¤t.path))) +} +fn known<'a>(w: &'a Task, dir: &str) -> (Option<&'a str>, Option) { + if dir.is_empty() { + (w.pr_provider.as_deref(), w.pr_number) + } else { + w.composition + .iter() + .find(|m| m.dir_name == dir) + .map(|m| (m.pr_provider.as_deref(), m.pr_number)) + .unwrap_or_default() + } +} +fn lookup(w: &Task, cwd: &Path, dir: &str) -> PrLookup { + let (p, n) = known(w, dir); + pr_lookup_at(cwd, n, p) +} +fn pr_for(w: &Task, cwd: &Path, dir: &str) -> Result { + let l = lookup(w, cwd, dir); + if l.status != "ok" { + return Err(l.message); + } + l.pr.ok_or_else(|| "No PR for this branch".into()) +} +fn persist_pr(id: &str, dir: &str, p: &forge::PrStatus) -> Result<(), String> { + let mut w = task(id)?; + if dir.is_empty() { + w.pr_url = Some(p.url.clone()); + w.pr_number = Some(p.number); + w.pr_provider = Some(p.provider.clone()); + } else if let Some(m) = w.composition.iter_mut().find(|m| m.dir_name == dir) { + m.pr_url = Some(p.url.clone()); + m.pr_number = Some(p.number); + m.pr_provider = Some(p.provider.clone()); + } else { + return Err("Repository removed from task".into()); + } + save_task(&w).map_err(|e| e.to_string()) +} +fn saved_path(id: &str) -> Result { + task(id)?; + Ok(scratch_dir(id)?.join("delivery.json")) +} +fn load(id: &str) -> Result { + let path = saved_path(id)?; + match fs::read(&path) { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| format!("Cannot read delivery drafts: {e}")) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Saved::default()), + Err(e) => Err(e.to_string()), + } +} +fn save(id: &str, s: &Saved) -> Result<(), String> { + write_atomic( + &saved_path(id)?, + &serde_json::to_vec_pretty(s).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string()) +} +fn safe_report(w: &Task, r: &Request) -> Result { + let root = dunce::canonicalize(&w.path).map_err(|e| e.to_string())?; + let path = root.join(".termic-delivery").join(format!("{}.json", r.id)); + if path.to_string_lossy() != r.report { + return Err("Report checkout changed".into()); + } + if !path.parent().ok_or("missing parent")?.exists() { + return Ok(path); + } + let parent = + dunce::canonicalize(path.parent().ok_or("missing parent")?).map_err(|e| e.to_string())?; + if parent != root.join(".termic-delivery") { + return Err("Report directory escapes the task".into()); + } + if fs::symlink_metadata(&path) + .map(|m| !m.is_file() || m.file_type().is_symlink()) + .unwrap_or(false) + { + return Err("Report must be a regular file".into()); + } + Ok(path) +} +fn dirs(w: &Task) -> Vec<(String, String, String)> { + let mut out = vec![( + String::new(), + load_projects_all() + .iter() + .find(|p| p.id == w.project_id) + .map(|p| p.name.clone()) + .unwrap_or_else(|| w.name.clone()), + if w.is_main_checkout { + "repo_root" + } else { + "worktree" + } + .into(), + )]; + out.extend(w.composition.iter().map(|m| { + ( + m.dir_name.clone(), + m.dir_name.clone(), + match m.mode { + MemberMode::RepoRoot => "repo_root", + MemberMode::Worktree => "worktree", + } + .into(), + ) + })); + out +} +fn repos(id: &str) -> Result, String> { + let w = task(id)?; + let projects = load_projects_all(); + Ok(dirs(&w) + .into_iter() + .map(|(dir, name, mode)| { + let base = repo_base_branch(&w, &dir, &projects); + match identity(&w, &dir) { + Ok(i) => { + let cwd = Path::new(&i.path); + let dirty = git(&["status", "--porcelain", "-uall"], cwd) + .map(|s| !s.trim().is_empty()) + .unwrap_or(true); + // A boolean only: merge-base + name-only is two cheap + // reads, not git_compare's full stat walk (which reads up + // to 8 MiB of untracked files per repo per refresh). When + // the worktree is dirty the UI shows that instead and + // archive_ready rejects on `dirty` first, so skip. + let changed = if base.is_empty() || dirty { + None + } else { + git(&["merge-base", &base, "HEAD"], cwd) + .ok() + .and_then(|mb| { + git(&["--no-pager", "diff", "--name-only", "-M", mb.trim()], cwd) + .ok() + }) + .map(|s| !s.trim().is_empty()) + }; + Repo { + dir_name: dir, + name, + mode, + base, + dirty, + changed, + identity: Some(i), + error: None, + } + } + Err(e) => Repo { + dir_name: dir, + name, + mode, + base, + dirty: false, + changed: None, + identity: None, + error: Some(e), + }, + } + }) + .collect()) +} +fn details(id: &str, expected: Identity) -> Result { + let (w, cwd) = validate(id, &expected)?; + let pr = pr_for(&w, &cwd, &expected.dir_name)?; + let revision = forge::delivery::revision(&pr.provider, &cwd, pr.number)?; + let ci = forge::delivery::ci(&pr.provider, &cwd, pr.number, &revision); + let threads = forge::delivery::threads(&pr.provider, &cwd, pr.number); + validate(id, &expected)?; + if forge::delivery::revision(&pr.provider, &cwd, pr.number)? != revision { + return Err("PR revision changed. Refresh.".into()); + } + Ok(Details { + identity: expected, + pr, + revision, + ci_error: ci.as_ref().err().cloned(), + ci: ci.unwrap_or_default(), + threads_error: threads.as_ref().err().cloned(), + threads: threads.unwrap_or_default(), + }) +} + +#[tauri::command] +pub async fn task_delivery_repos(id: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || repos(&id)) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_details(id: String, expected: Identity) -> Result { + tauri::async_runtime::spawn_blocking(move || details(&id, expected)) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_validate(id: String, expected: Vec) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + for i in expected { + validate(&id, &i)?; + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_log( + id: String, + expected: Identity, + log_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let d = details(&id, expected.clone())?; + if !d.ci.iter().any(|n| n.log_id.as_deref() == Some(&log_id)) { + return Err("Log no longer belongs to this PR revision".into()); + } + let result = forge::delivery::log(&d.pr.provider, Path::new(&expected.path), &log_id)?; + validate(&id, &expected)?; + Ok(result) + }) + .await + .map_err(|e| e.to_string())? +} +/// Import one request's report file. `Ok(None)` = no report on disk yet; +/// `Ok(Some(mutated))` = the file was read and applied (or had nothing new). +/// `Err` leaves the request untouched - the caller records it on that one +/// request so a malformed report cannot wedge every other request's listing. +fn import_report(w: &Task, r: &mut Request) -> Result, String> { + let path = safe_report(w, r)?; + if !path.exists() { + return Ok(None); + } + let bytes = read_report(&path)?; + let report = serde_json::from_slice::(&bytes) + .map_err(|_| "Report is not valid JSON".to_string())?; + let mut mutated = false; + if let Some(list) = report["drafts"].as_array() { + for item in list { + let key = item["key"].as_str().ok_or("Draft report has no item key")?; + let body = item["body"].as_str().ok_or("Draft report has no body")?; + if body.len() > 32000 { + return Err("Draft reply exceeds 32,000 bytes".into()); + } + let d = r + .drafts + .iter_mut() + .find(|d| d.key == key) + .ok_or("Draft report contains an unrequested thread")?; + if d.status == "draft" && d.body.is_empty() { + d.body = body.into(); + mutated = true; + } + } + } + if let Some(list) = report["prs"].as_array() { + if !list.is_empty() && r.kind != "prs" { + return Err("Unexpected PR drafts in report".into()); + } + let mut seen = HashSet::new(); + for item in list { + let dir = item["dir_name"] + .as_str() + .ok_or("PR draft has no repository selector")?; + if !seen.insert(dir) { + return Err("Duplicate PR draft".into()); + } + if !r.identities.iter().any(|i| i.dir_name == dir) { + return Err("PR report contains an unrequested repository".into()); + } + let title = item["title"].as_str().ok_or("PR draft has no title")?; + let body = item["body"].as_str().ok_or("PR draft has no body")?; + if title.len() > 1000 || body.len() > 32000 { + return Err("PR draft is too large".into()); + } + r.prs.retain(|p| p.dir_name != dir); + r.prs.push(PrText { + dir_name: dir.into(), + title: title.into(), + body: body.into(), + }); + mutated = true; + } + } + if r.kind == "prs" && r.prs.is_empty() { + return Ok(Some(mutated)); + } + if !r.drafts.is_empty() && r.drafts.iter().any(|d| d.body.is_empty()) { + return Ok(Some(mutated)); + } + r.status = "drafted".into(); + Ok(Some(true)) +} + +#[tauri::command] +pub async fn task_delivery_requests(id: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + let w = task(&id)?; + let mut data = load(&id)?; + let mut changed = false; + for r in &mut data.requests { + if !matches!(r.status.as_str(), "sent" | "queued" | "uncertain") { + continue; + } + match import_report(&w, r) { + // An unread report leaves any send-time error alone; a read + // report resolves whatever import error preceded it. + Ok(None) => {} + Ok(Some(mutated)) => { + changed |= mutated; + if r.error.take().is_some() { + changed = true; + } + } + Err(e) => { + if r.error.as_deref() != Some(e.as_str()) { + r.error = Some(e); + changed = true; + } + } + } + } + if changed { + save(&id, &data)?; + } + Ok(data.requests) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_request( + id: String, + expected: Vec, + drafts: Vec, + kind: String, + scope: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if expected.is_empty() || expected.len() > 64 || drafts.len() > 100 { + return Err("Select 1-64 repositories and at most 100 threads".into()); + } + if !["fix", "replies", "prs", "conflicts"].contains(&kind.as_str()) { + return Err("Invalid request kind".into()); + } + let w = task(&id)?; + let mut selected = HashSet::new(); + for i in &expected { + if !selected.insert(&i.dir_name) { + return Err("Duplicate repository selection".into()); + } + } + for i in &expected { + validate(&id, i)?; + } + let mut keys = HashSet::new(); + let mut details_cache = HashMap::new(); + for d in &drafts { + if !keys.insert(&d.key) { + return Err("Duplicate draft item".into()); + } + let i = expected + .iter() + .find(|i| i.dir_name == d.dir_name) + .ok_or("Draft repository not selected")?; + if !details_cache.contains_key(&d.dir_name) { + details_cache.insert(d.dir_name.clone(), details(&id, i.clone())?); + } + let det = &details_cache[&d.dir_name]; + if det.pr.number != d.pr_number + || !det + .threads + .iter() + .any(|t| t.id == d.thread_id && t.reply_id == d.reply_id) + { + return Err("Review thread changed. Refresh.".into()); + } + } + let root = dunce::canonicalize(&w.path).map_err(|e| e.to_string())?; + let dir = root.join(".termic-delivery"); + if let Ok(m) = fs::symlink_metadata(&dir) { + if !m.is_dir() || m.file_type().is_symlink() { + return Err("Report directory must be a real task directory".into()); + } + } + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + // A symlink planted between the check and the create could aim the + // report dir outside the worktree; re-verify after create. + if dunce::canonicalize(&dir).map_err(|e| e.to_string())? != root.join(".termic-delivery") { + return Err("Report directory escapes the task".into()); + } + // git's own common-dir resolver works for .git FILES in worktrees. + { + let common = + git(&["rev-parse", "--git-common-dir"], &root).map_err(|e| e.to_string())?; + let common = root.join(common.trim()); + let file = common.join("info/exclude"); + let mut body = fs::read_to_string(&file).unwrap_or_default(); + if !body.lines().any(|l| l == "/.termic-delivery/") { + if !body.ends_with('\n') { + body.push('\n'); + } + body.push_str("/.termic-delivery/\n"); + write_atomic(&file, body.as_bytes()).map_err(|e| e.to_string())?; + } + git(&["check-ignore", ".termic-delivery/report.json"], &root) + .map_err(|_| "Report directory could not be ignored")?; + } + let request_id = Uuid::new_v4().to_string(); + let r = Request { + id: request_id.clone(), + kind, + identities: expected, + report: dir + .join(format!("{request_id}.json")) + .to_string_lossy() + .into_owned(), + status: "prepared".into(), + error: None, + // Display-only label; cap so a hostile selection can't bloat the + // durable file. + scope: scope.unwrap_or_default().chars().take(500).collect(), + drafts: drafts + .into_iter() + .map(|mut d| { + d.body.clear(); + d.status = "draft".into(); + d.error = None; + d + }) + .collect(), + prs: vec![], + }; + let mut data = load(&id)?; + data.requests.push(r.clone()); + save(&id, &data)?; + Ok(r) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_request_status( + id: String, + request_id: String, + status: String, + error: Option, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if !["queued", "sent", "failed", "uncertain"].contains(&status.as_str()) { + return Err("Invalid delivery status".into()); + } + let mut data = load(&id)?; + let r = data + .requests + .iter_mut() + .find(|r| r.id == request_id) + .ok_or("Unknown delivery request")?; + // Loose transitions let a stale queue item re-"send" a request that + // was already sent (the prompt would be typed twice). Same-status is + // an idempotent no-op; 'drafted' is terminal. + let legal = r.status == status + || matches!( + (r.status.as_str(), status.as_str()), + ("prepared", "queued" | "sent" | "failed" | "uncertain") + | ("queued", "sent" | "failed" | "uncertain") + | ("sent", "failed" | "uncertain") + | ("uncertain", "sent" | "failed") + | ("failed", "queued" | "sent") + ); + if !legal { + return Err(format!( + "Delivery request is '{}', cannot become '{status}'", + r.status + )); + } + r.status = status; + r.error = error; + save(&id, &data) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_draft_save( + id: String, + request_id: String, + key: String, + body: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if body.len() > 32000 { + return Err("Reply exceeds 32,000 bytes".into()); + } + let mut data = load(&id)?; + let d = data + .requests + .iter_mut() + .find(|r| r.id == request_id) + .and_then(|r| r.drafts.iter_mut().find(|d| d.key == key)) + .ok_or("Unknown reply draft")?; + if d.status != "draft" { + return Err("Posted or uncertain reply cannot be edited".into()); + } + d.body = body; + save(&id, &data) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_reply_post( + id: String, + request_id: String, + key: String, + expected: Identity, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + let (w, cwd) = validate(&id, &expected)?; + let mut data = load(&id)?; + let r = data + .requests + .iter_mut() + .find(|r| r.id == request_id) + .ok_or("Unknown delivery request")?; + let original = r + .identities + .iter() + .find(|i| i.dir_name == expected.dir_name) + .ok_or("Original repository unavailable")?; + if original.path != expected.path + || original.remote != expected.remote + || original.branch != expected.branch + { + return Err("Reply destination changed. Draft retained.".into()); + } + let marker = format!( + "", + r.id, + Sha256::digest(key.as_bytes()) + ); + let d = r + .drafts + .iter_mut() + .find(|d| d.key == key && d.dir_name == expected.dir_name) + .ok_or("Unknown reply draft")?; + if d.status == "posted" { + return Ok(()); + } + if d.body.trim().is_empty() { + return Err("Reply is empty".into()); + } + let pr = pr_for(&w, &cwd, &expected.dir_name)?; + if pr.number != d.pr_number { + return Err("PR changed. Reply retained.".into()); + } + let threads = forge::delivery::threads(&pr.provider, &cwd, pr.number)?; + let thread = threads + .iter() + .find(|t| t.id == d.thread_id && t.reply_id == d.reply_id) + .ok_or("Thread no longer available. Reply retained.")?; + if reconcile_reply_readback(d, thread, &marker) { + save(&id, &data)?; + return Ok(()); + } + validate(&id, &expected)?; + let reply = format!("{}\n\n{}", d.body, marker); + let thread = thread.clone(); + d.status = "posting".into(); + save(&id, &data)?; + let result = forge::delivery::post_reply(&pr.provider, &cwd, pr.number, &thread, &reply); + let d = data + .requests + .iter_mut() + .find(|r| r.id == request_id) + .and_then(|r| r.drafts.iter_mut().find(|d| d.key == key)) + .ok_or("Reply disappeared")?; + d.status = if result.is_ok() { + "posted" + } else { + "uncertain" + } + .into(); + d.error = result.as_ref().err().cloned(); + save(&id, &data)?; + result + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_pr_create( + id: String, + inputs: Vec, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if inputs.is_empty() || inputs.len() > 64 { + return Err("Select 1-64 repositories".into()); + } + let mut results = vec![]; + let mut seen = HashSet::new(); + for input in inputs { + let dir = input.identity.dir_name.clone(); + if !seen.insert(dir.clone()) { + return Err("Duplicate repository selection".into()); + } + let outcome = (|| { + let (w, cwd) = validate(&id, &input.identity)?; + let l = lookup(&w, &cwd, &dir); + if l.status != "ok" { + return Err(l.message); + } + if let Some(p) = l.pr { + if matches!(p.state.as_str(), "open" | "draft") { + persist_pr(&id, &dir, &p)?; + return Ok(p.url); + } + } + if input.identity.branch.is_empty() { + return Err("Detached HEAD. Check out a branch first.".into()); + } + if !git(&["status", "--porcelain", "-uall"], &cwd) + .map_err(|e| e.to_string())? + .trim() + .is_empty() + { + return Err("Commit or discard local changes before creating a PR.".into()); + } + let base = input.base.trim(); + if base.is_empty() || input.title.trim().is_empty() { + return Err("Title and base branch are required".into()); + } + if git_compare(&cwd, base, true) + .map_err(|e| e.to_string())? + .files + .is_empty() + { + return Err("No branch changes against the selected base. Skipped.".into()); + } + let provider = l.provider.ok_or("Unsupported provider")?; + let remote = detect_default_remote(&cwd); + let prefix = format!("{remote}/"); + let base = base.strip_prefix(&prefix).unwrap_or(base); + validate(&id, &input.identity)?; + git_push(&cwd)?; + validate(&id, &input.identity)?; + let url = forge::pr_create( + &provider, + &cwd, + input.title.trim(), + input.body.trim(), + base, + input.draft, + ) + .map_err(|e| e.to_string())?; + let number = forge::pr_number_from_url(&url).ok_or( + "Created PR URL could not be read. Open the provider before retrying.", + )?; + // Persist the URL even if the immediately-following status request fails. + let p = forge::PrStatus { + provider, + number, + url: url.clone(), + title: input.title, + state: if input.draft { "draft" } else { "open" }.into(), + checks: "none".into(), + review: "none".into(), + base: base.into(), + head: input.identity.branch, + }; + persist_pr(&id, &dir, &p)?; + Ok(url) + })(); + results.push(ActionResult { + dir_name: dir.clone(), + name: if dir.is_empty() { "Host".into() } else { dir }, + url: outcome.as_ref().ok().cloned(), + result: None, + error: outcome.err(), + }); + } + store_results(&id, results) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_update( + id: String, + expected: Vec, + mode: UpdateMode, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if expected.is_empty() || expected.len() > 64 { + return Err("Select 1-64 repositories".into()); + } + let mut seen = HashSet::new(); + let results = expected + .into_iter() + .map(|i| { + let dir = i.dir_name.clone(); + let outcome = (|| { + if !seen.insert(dir.clone()) { + return Err("Duplicate repository selection".into()); + } + let (w, cwd) = validate(&id, &i)?; + let base = repo_base_branch(&w, &dir, &load_projects_all()); + git_update_repo(&cwd, mode, &base) + })(); + ActionResult { + dir_name: dir.clone(), + name: if dir.is_empty() { "Host".into() } else { dir }, + url: None, + error: outcome.as_ref().err().cloned(), + result: outcome.ok(), + } + }) + .collect(); + store_results(&id, results) + }) + .await + .map_err(|e| e.to_string())? +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn preview_detects_tracked_edits_and_branch_changes() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + git(&["init", "-b", "main"], cwd).unwrap(); + git(&["config", "user.email", "user@example.com"], cwd).unwrap(); + git(&["config", "user.name", "alice"], cwd).unwrap(); + fs::write(cwd.join("a"), "one").unwrap(); + git(&["add", "a"], cwd).unwrap(); + git(&["commit", "-m", "initial"], cwd).unwrap(); + let w = Task { + path: cwd.to_string_lossy().into_owned(), + ..Default::default() + }; + let first = identity(&w, "").unwrap(); + fs::write(cwd.join("a"), "two").unwrap(); + let second = identity(&w, "").unwrap(); + assert_ne!(first.worktree, second.worktree); + git(&["checkout", "-b", "other"], cwd).unwrap(); + assert_ne!(identity(&w, "").unwrap().branch, first.branch); + } +} + +#[tauri::command] +pub async fn task_delivery_results(id: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + Ok(load(&id)?.results) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_archive_ready(id: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let w = task(&id)?; + if w.is_main_checkout { + return Ok(false); + } + let rows = repos(&id)?; + for row in rows { + let Some(i) = row.identity else { + return Ok(false); + }; + if row.dirty || row.changed.is_none() { + return Ok(false); + } + let l = lookup(&w, Path::new(&i.path), &i.dir_name); + if l.status != "ok" { + return Ok(false); + } + match l.pr { + Some(p) if p.state == "merged" => {} + None if row.changed == Some(false) => {} + _ => return Ok(false), + } + } + Ok(true) + }) + .await + .map_err(|e| e.to_string())? +} + +fn store_results(id: &str, results: Vec) -> Result, String> { + let mut data = load(id)?; + for result in results { + data.results.retain(|r| r.dir_name != result.dir_name); + data.results.push(result); + } + save(id, &data)?; + Ok(data.results) +} + +#[cfg(test)] +mod persistence_tests { + use super::*; + use crate::test_support::with_scratch_data_dir; + + #[test] + fn reports_are_local_scoped_and_results_survive_reload() { + with_scratch_data_dir(|_| { + let checkout = tempfile::tempdir().unwrap(); + let root = checkout.path(); + git(&["init", "-b", "main"], root).unwrap(); + git(&["config", "user.email", "fixture@example.test"], root).unwrap(); + git(&["config", "user.name", "Fixture"], root).unwrap(); + fs::write(root.join("README.md"), "fixture").unwrap(); + git(&["add", "README.md"], root).unwrap(); + git(&["commit", "-m", "fixture"], root).unwrap(); + let w = Task { + id: Uuid::new_v4().to_string(), + name: "Fixture".into(), + path: root.to_string_lossy().into_owned(), + ..Default::default() + }; + save_task(&w).unwrap(); + let reviewed = identity(&w, "").unwrap(); + let request = tauri::async_runtime::block_on(task_delivery_request( + w.id.clone(), + vec![reviewed.clone()], + vec![], + "prs".into(), + Some("app".into()), + )) + .unwrap(); + assert_eq!( + identity(&w, "").unwrap(), + reviewed, + "ignored reports must not invalidate the preview" + ); + tauri::async_runtime::block_on(task_delivery_request_status( + w.id.clone(), + request.id.clone(), + "sent".into(), + None, + )) + .unwrap(); + fs::write( + &request.report, + r#"{"prs":[{"dir_name":"outside","title":"wrong","body":""}]}"#, + ) + .unwrap(); + // A malformed report must not fail the listing: it lands on the + // offending request and leaves the status alone so a corrected + // rewrite still imports. + let listed = + tauri::async_runtime::block_on(task_delivery_requests(w.id.clone())).unwrap(); + assert_eq!( + listed[0].error.as_deref(), + Some("PR report contains an unrequested repository") + ); + assert_eq!(listed[0].status, "sent"); + assert!(listed[0].prs.is_empty()); + fs::write(&request.report, r#"{"prs":[{"dir_name":"","title":"Reviewed title","body":"Proposed body"}],"drafts":[]}"#).unwrap(); + let imported = + tauri::async_runtime::block_on(task_delivery_requests(w.id.clone())).unwrap(); + assert_eq!(imported[0].prs[0].title, "Reviewed title"); + assert_eq!(imported[0].status, "drafted"); + assert_eq!(imported[0].error, None); + assert!( + task(&w.id).unwrap().pr_number.is_none(), + "import does not create or persist a PR" + ); + store_results( + &w.id, + vec![ActionResult { + dir_name: "".into(), + name: "Host".into(), + url: Some("https://example.test/pull/7".into()), + result: None, + error: None, + }], + ) + .unwrap(); + store_results( + &w.id, + vec![ActionResult { + dir_name: "api".into(), + name: "api".into(), + url: None, + result: None, + error: Some("offline".into()), + }], + ) + .unwrap(); + assert_eq!( + load(&w.id).unwrap().results.len(), + 2, + "retrying selected repos keeps other results" + ); + fs::write(root.join("README.md"), "changed").unwrap(); + assert!(validate(&w.id, &reviewed).is_err()); + }); + } + + #[test] + fn request_status_transitions_reject_re_sends_and_dead_states() { + with_scratch_data_dir(|_| { + let checkout = tempfile::tempdir().unwrap(); + let root = checkout.path(); + git(&["init", "-b", "main"], root).unwrap(); + git(&["config", "user.email", "fixture@example.test"], root).unwrap(); + git(&["config", "user.name", "Fixture"], root).unwrap(); + fs::write(root.join("README.md"), "fixture").unwrap(); + git(&["add", "README.md"], root).unwrap(); + git(&["commit", "-m", "fixture"], root).unwrap(); + let w = Task { + id: Uuid::new_v4().to_string(), + name: "Fixture".into(), + path: root.to_string_lossy().into_owned(), + ..Default::default() + }; + save_task(&w).unwrap(); + let request = tauri::async_runtime::block_on(task_delivery_request( + w.id.clone(), + vec![identity(&w, "").unwrap()], + vec![], + "prs".into(), + None, + )) + .unwrap(); + let set = |to: &str| { + tauri::async_runtime::block_on(task_delivery_request_status( + w.id.clone(), + request.id.clone(), + to.into(), + None, + )) + }; + set("queued").unwrap(); + set("queued").expect("same-status writes are idempotent"); + set("sent").unwrap(); + assert!(set("queued").is_err(), "a sent request cannot re-queue"); + assert!(set("sent").is_ok()); + // 'drafted' (the report-imported end state) is terminal. + let mut data = load(&w.id).unwrap(); + data.requests[0].status = "drafted".into(); + save(&w.id, &data).unwrap(); + assert!(set("failed").is_err()); + }); + } + + #[test] + fn import_report_rejects_unrequested_content_and_keeps_local_edits() { + let checkout = tempfile::tempdir().unwrap(); + // safe_report compares the stored path against the CANONICAL task + // root — /var/… resolves to /private/var/… on macOS. + let root = dunce::canonicalize(checkout.path()).unwrap(); + let w = Task { + path: root.to_string_lossy().into_owned(), + ..Default::default() + }; + let dir = root.join(".termic-delivery"); + fs::create_dir_all(&dir).unwrap(); + let draft = |key: &str, body: &str| Draft { + key: key.into(), + dir_name: String::new(), + pr_number: 1, + thread_id: "t".into(), + reply_id: "x".into(), + body: body.into(), + status: "draft".into(), + error: None, + }; + let mut r = Request { + id: "r1".into(), + identities: vec![], + report: dir.join("r1.json").to_string_lossy().into_owned(), + status: "sent".into(), + error: None, + kind: "replies".into(), + drafts: vec![draft("a", ""), draft("b", "local edit")], + prs: vec![], + scope: String::new(), + }; + // A replies report cannot smuggle PR drafts past the request kind. + fs::write(dir.join("r1.json"), r#"{"prs":[{"dir_name":"","title":"t","body":"b"}]}"#).unwrap(); + assert_eq!( + import_report(&w, &mut r).unwrap_err(), + "Unexpected PR drafts in report" + ); + // Reports fill empty drafts only: a body the user already saved + // locally is not overwritten by what the agent produced. + fs::write( + dir.join("r1.json"), + r#"{"drafts":[{"key":"a","body":"from agent"},{"key":"b","body":"overwrite?"}]}"#, + ) + .unwrap(); + assert_eq!(import_report(&w, &mut r), Ok(Some(true))); + assert_eq!(r.drafts[0].body, "from agent"); + assert_eq!(r.drafts[1].body, "local edit"); + assert_eq!(r.status, "drafted"); + } +} + +fn read_report(path: &Path) -> Result, String> { + use std::io::Read; + #[cfg(unix)] + let file = { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::OpenOptionsExt; + // Pin the directory before opening its child. A replacement symlink + // cannot redirect the report read between validation and open. + let directory = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(path.parent().ok_or("Missing report parent")?) + .map_err(|e| e.to_string())?; + let name = + std::ffi::CString::new(path.file_name().ok_or("Missing report name")?.as_bytes()) + .map_err(|e| e.to_string())?; + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + unsafe { fs::File::from_raw_fd(fd) } + }; + #[cfg(not(unix))] + let file = fs::File::open(path).map_err(|e| e.to_string())?; + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("Report must be a regular file".into()); + } + let mut bytes = Vec::new(); + file.take(256 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > 256 * 1024 { + return Err("Draft report exceeds 256 KiB".into()); + } + Ok(bytes) +} + +#[cfg(test)] +mod report_read_tests { + use super::*; + #[test] + fn oversized_report_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("report.json"); + fs::write(&path, vec![b' '; 256 * 1024 + 1]).unwrap(); + assert!(read_report(&path).is_err()); + } + #[cfg(unix)] + #[test] + fn report_symlink_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("report.json"); + fs::write(tmp.path().join("other"), b"{}").unwrap(); + std::os::unix::fs::symlink(tmp.path().join("other"), &path).unwrap(); + assert!(read_report(&path).is_err()); + } +} + +// True means this call only reconciles an earlier attempt, never posts. +fn reconcile_reply_readback(d: &mut Draft, thread: &ReviewThread, marker: &str) -> bool { + let reply = format!("{}\n\n{}", d.body, marker); + if thread.comments.iter().any(|c| c.body == reply) { + d.status = "posted".into(); + d.error = None; + return true; + } + if matches!(d.status.as_str(), "posting" | "uncertain") { + d.status = "retry_ready".into(); + d.error=Some("Provider readback did not find this reply. Check the thread before explicitly retrying.".into()); + return true; + } + false +} +#[cfg(test)] +mod reply_tests { + use super::*; + #[test] + fn readback_requires_an_explicit_second_action_and_catches_late_posts() { + let mut d = Draft { + key: "thread".into(), + dir_name: "".into(), + pr_number: 7, + thread_id: "t".into(), + reply_id: "1".into(), + body: "Reviewed reply".into(), + status: "uncertain".into(), + error: None, + }; + let mut thread = ReviewThread { + id: "t".into(), + reply_id: "1".into(), + path: None, + line: None, + resolved: Some(false), + url: String::new(), + comments: vec![], + }; + assert!(reconcile_reply_readback(&mut d, &thread, "")); + assert_eq!(d.status, "retry_ready"); + assert!( + !reconcile_reply_readback(&mut d, &thread, ""), + "a second explicit action may post after fresh readback" + ); + thread.comments.push(ThreadComment { + id: "2".into(), + author: "Fixture".into(), + body: "Reviewed reply\n\n".into(), + }); + assert!(reconcile_reply_readback(&mut d, &thread, "")); + assert_eq!(d.status, "posted"); + } +} diff --git a/src-tauri/src/forge.rs b/src-tauri/src/forge.rs index d5f68273..8df8a71e 100644 --- a/src-tauri/src/forge.rs +++ b/src-tauri/src/forge.rs @@ -145,7 +145,7 @@ fn probe_authed_hosts() -> HashMap { out } -fn auth_text(o: &std::process::Output) -> String { +fn auth_text(o: &CmdOut) -> String { format!( "{}\n{}", String::from_utf8_lossy(&o.stdout), @@ -223,7 +223,19 @@ fn reprobe_bin(name: &str) -> Option { resolved } -fn run(bin: &str, args: &[&str], cwd: Option<&Path>) -> std::io::Result { +/// `std::process::Output` cannot be constructed outside `Command::output()`, +/// and `output()` has no deadline — a forge CLI parked on a credential +/// prompt or a dead socket would pin its spawn_blocking thread (and, for +/// delivery commands running under the process-global lock, every other +/// delivery call) forever. Same spawn contract, same field names, one change: +/// a hard wall-clock ceiling. +pub struct CmdOut { + pub status: std::process::ExitStatus, + pub stdout: Vec, + pub stderr: Vec, +} + +fn run(bin: &str, args: &[&str], cwd: Option<&Path>) -> std::io::Result { let mut cmd = crate::proc_ctl::command(bin); cmd.args(args) // Login-shell PATH so the CLI can find its own helpers (git, @@ -244,7 +256,60 @@ fn run(bin: &str, args: &[&str], cwd: Option<&Path>) -> std::io::Result>>| { + h.and_then(|h| h.join().ok()).unwrap_or_default() + }; + // REST calls land in seconds; the ceiling only fires on a genuinely + // wedged child (dead host, ignored prompt-disable env, hung helper). + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + loop { + match child.try_wait() { + Ok(Some(status)) => { + return Ok(CmdOut { + status, + stdout: join(stdout), + stderr: join(stderr), + }); + } + Ok(None) => { + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + let _ = join(stdout); + let _ = join(stderr); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("{bin} did not exit within 120s"), + )); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(e) => return Err(e), + } + } } // ───────────────────────── detection (Settings / hints) ───────────────────────── @@ -601,13 +666,22 @@ pub enum ForgeError { Other(String), } -fn stderr_of(o: &std::process::Output) -> String { +fn stderr_of(o: &CmdOut) -> String { String::from_utf8_lossy(&o.stderr).trim().to_string() } /// Classify a failed CLI invocation: auth problems get their own arm so /// the UI can say "run gh auth login" instead of dumping stderr. -fn classify_failure(provider: &str, o: &std::process::Output) -> ForgeError { +impl std::fmt::Display for ForgeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CliMissing(cli) => write!(f, "The {cli} CLI is not installed"), + Self::Auth(message) | Self::Other(message) => f.write_str(message), + } + } +} + +fn classify_failure(provider: &str, o: &CmdOut) -> ForgeError { classify_stderr(provider, &stderr_of(o)) } @@ -2944,3 +3018,6 @@ code.internal.acme.com configured to use ssh protocol.\n"; assert_eq!(out[0].created_at, "2026-06-12T09:00:00Z"); } } + +#[path = "forge_delivery.rs"] +pub mod delivery; diff --git a/src-tauri/src/forge_delivery.rs b/src-tauri/src/forge_delivery.rs new file mode 100644 index 00000000..62c0f223 --- /dev/null +++ b/src-tauri/src/forge_delivery.rs @@ -0,0 +1,1070 @@ +//! Provider detail operations. Authentication remains owned by gh/glab/az. +use super::*; +use crate::delivery::{CiNode, ReviewThread, ThreadComment}; +use serde_json::{json, Value}; + +fn text(v: &Value, key: &str) -> String { + v[key].as_str().unwrap_or("").to_string() +} +fn ident(v: &Value, key: &str) -> String { + v[key] + .as_str() + .map(str::to_string) + .or_else(|| v[key].as_u64().map(|n| n.to_string())) + .unwrap_or_default() +} +fn checked_output( + provider: &str, + cwd: &Path, + args: &[String], +) -> Result { + let cli = cli_for_provider(provider); + let bin = reprobe_bin(cli).ok_or_else(|| format!("{cli} is not installed"))?; + let mut argv = args.to_vec(); + // gh api otherwise defaults to github.com even in an Enterprise checkout. + if provider == GITHUB && argv.first().map(String::as_str) == Some("api") { + let remote = crate::git( + &["remote", "get-url", &crate::detect_default_remote(cwd)], + cwd, + ) + .map_err(|e| e.to_string())?; + let host = host_of_remote(&remote).ok_or("cannot resolve GitHub host")?; + argv.extend(["--hostname".into(), host]); + } + let refs: Vec<_> = argv.iter().map(String::as_str).collect(); + let out = run(&bin, &refs, Some(cwd)).map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(format!("{}", classify_failure(provider, &out))); + } + Ok(out) +} +fn cli_json(provider: &str, cwd: &Path, args: Vec) -> Result { + let out = checked_output(provider, cwd, &args)?; + serde_json::from_slice(&out.stdout).map_err(|e| format!("invalid provider JSON: {e}")) +} +fn args(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() +} + +fn rest(provider: &str, cwd: &Path, path: &str, body: Option<&Value>) -> Result { + let mut argv = args(&["api", path]); + let temporary = body + .map(|_| std::env::temp_dir().join(format!("termic-reply-{}.json", uuid::Uuid::new_v4()))); + if let (Some(body), Some(file)) = (body, &temporary) { + private_body(file, body)?; + argv.extend([ + "--method".into(), + "POST".into(), + "--input".into(), + file.to_string_lossy().into_owned(), + ]); + } + let result = cli_json(provider, cwd, argv); + if let Some(file) = temporary { + let _ = std::fs::remove_file(file); + } + result +} +fn private_body(path: &Path, value: &Value) -> Result<(), String> { + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path).map_err(|e| e.to_string())?; + file.write_all(value.to_string().as_bytes()) + .map_err(|e| e.to_string()) +} +fn pages(provider: &str, cwd: &Path, path: &str) -> Result, String> { + let mut all = vec![]; + // Explicit ceiling prevents a large MR monopolizing the app's worker pool. + for page in 1..=20 { + let sep = if path.contains('?') { '&' } else { '?' }; + let v = rest( + provider, + cwd, + &format!("{path}{sep}per_page=100&page={page}"), + None, + )?; + let batch = v.as_array().ok_or("expected a provider list")?; + all.extend(batch.iter().cloned()); + if batch.len() < 100 { + return Ok(all); + } + } + Err("More than 2,000 items. Open the full list on the provider.".into()) +} +fn azure( + cwd: &Path, + area: &str, + resource: &str, + route: &[(&str, String)], + body: Option<&Value>, +) -> Result { + let (org, project, _) = azure_scope(cwd).map_err(|e| e.to_string())?; + let mut argv = args(&[ + "devops", + "invoke", + "--area", + area, + "--resource", + resource, + "--org", + &org, + "--api-version", + "7.1", + "--output", + "json", + "--route-parameters", + ]); + argv.push(format!("project={project}")); + argv.extend(route.iter().map(|(k, v)| format!("{k}={v}"))); + let file = body + .map(|_| std::env::temp_dir().join(format!("termic-reply-{}.json", uuid::Uuid::new_v4()))); + if let (Some(body), Some(file)) = (body, &file) { + private_body(file, body)?; + argv.extend([ + "--http-method".into(), + "POST".into(), + "--in-file".into(), + file.to_string_lossy().into_owned(), + ]); + } + let result = cli_json(AZURE, cwd, argv); + if let Some(file) = file { + let _ = std::fs::remove_file(file); + } + result +} + +pub fn revision(provider: &str, cwd: &Path, number: u64) -> Result { + let n = number.to_string(); + let v = match provider { + GITHUB => rest( + provider, + cwd, + &format!("repos/{{owner}}/{{repo}}/pulls/{n}"), + None, + )?, + GITLAB => rest( + provider, + cwd, + &format!("projects/:id/merge_requests/{n}"), + None, + )?, + AZURE => { + let (org, project, _) = azure_scope(cwd).map_err(|e| e.to_string())?; + cli_json( + provider, + cwd, + args(&[ + "repos", + "pr", + "show", + "--id", + &n, + "--org", + &org, + "--project", + &project, + "-o", + "json", + ]), + )? + } + _ => return Err("unsupported provider".into()), + }; + let sha = match provider { + GITHUB => text(&v["head"], "sha"), + GITLAB => text(&v, "sha"), + _ => text(&v["lastMergeSourceCommit"], "commitId"), + }; + if !crate::is_commit_ish(&sha) { + return Err("provider did not identify the PR revision".into()); + } + Ok(sha) +} +fn state(raw: &str) -> String { + match raw.to_ascii_lowercase().as_str() { + "success" | "succeeded" | "passed" | "approved" => "passed", + "failure" | "failed" | "timed_out" | "startup_failure" | "rejected" | "broken" => "failed", + "cancelled" | "canceled" => "canceled", + "skipped" | "neutral" | "notapplicable" => "skipped", + "action_required" | "manual" | "waiting" => "approval", + "in_progress" | "running" => "running", + "queued" + | "pending" + | "created" + | "notstarted" + | "preparing" + | "scheduled" + | "waiting_for_resource" => "pending", + _ => "unknown", + } + .to_string() +} +fn duration(v: &Value, start: &str, end: &str) -> Option { + let a = chrono::DateTime::parse_from_rfc3339(v[start].as_str()?).ok()?; + let b = chrono::DateTime::parse_from_rfc3339(v[end].as_str()?).ok()?; + Some((b - a).num_milliseconds().max(0) as f64 / 1000.) +} +fn node( + id: String, + parent: Option, + name: String, + kind: &str, + status: &str, + url: String, + log_id: Option, +) -> CiNode { + CiNode { + id, + parent, + name, + kind: kind.into(), + status: state(status), + url, + log_id, + duration: None, + } +} + +pub fn ci(provider: &str, cwd: &Path, number: u64, expected: &str) -> Result, String> { + match provider { + GITHUB => github_ci(cwd, number, expected), + GITLAB => gitlab_ci(cwd, number, expected), + AZURE => azure_ci(cwd, number, expected), + _ => Err("unsupported provider".into()), + } +} +fn github_ci(cwd: &Path, number: u64, expected: &str) -> Result, String> { + let pr = rest( + GITHUB, + cwd, + &format!("repos/{{owner}}/{{repo}}/pulls/{number}"), + None, + )?; + if text(&pr["head"], "sha") != expected { + return Err("PR revision changed. Refresh before reviewing CI.".into()); + } + let mut revisions = vec![expected.to_string()]; + if pr["mergeable"].as_bool() == Some(true) { + if let Some(sha) = pr["merge_commit_sha"] + .as_str() + .filter(|s| crate::is_commit_ish(s)) + { + if merge_revision_matches(GITHUB, cwd, sha, expected)? { + revisions.push(sha.into()); + } + } + } + let mut runs = HashSet::new(); + let mut nodes = vec![]; + for sha in &revisions { + let checks = rest( + GITHUB, + cwd, + &format!("repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100"), + None, + )?; + let list = checks["check_runs"] + .as_array() + .ok_or("missing GitHub checks")?; + if checks["total_count"].as_u64().unwrap_or(0) > list.len() as u64 { + return Err("More than 100 checks. Open the full checks list on GitHub.".into()); + } + for check in list { + let url = text(check, "details_url"); + let parts: Vec<_> = url.split('/').collect(); + let run = parts + .iter() + .position(|p| *p == "runs") + .and_then(|i| parts.get(i + 1)) + .and_then(|s| s.parse::().ok()); + if let Some(run) = run { + runs.insert(run); + } else { + let status = check["conclusion"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| check["status"].as_str().unwrap_or("")); + nodes.push(node( + format!("check:{}", ident(check, "id")), + None, + text(check, "name"), + "check", + status, + url, + None, + )); + } + } + let statuses = pages( + GITHUB, + cwd, + &format!("repos/{{owner}}/{{repo}}/commits/{sha}/statuses"), + )?; + let mut seen = HashSet::new(); + for status in statuses { + let name = text(&status, "context"); + if !seen.insert(name.clone()) { + continue; + } + nodes.push(node( + format!("status:{}", ident(&status, "id")), + None, + name, + "check", + &text(&status, "state"), + text(&status, "target_url"), + None, + )); + } + } + for run in runs { + let v = cli_json( + GITHUB, + cwd, + args(&[ + "run", + "view", + &run.to_string(), + "--json", + "jobs,workflowName,headSha,headBranch,url,status,conclusion", + ]), + )?; + if !github_run_matches(&v, &text(&pr["head"], "ref"), &revisions) { + continue; + } + let root = format!("gh:{run}"); + let verdict = v["conclusion"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| v["status"].as_str().unwrap_or("")); + nodes.push(node( + root.clone(), + None, + text(&v, "workflowName"), + "workflow", + verdict, + text(&v, "url"), + None, + )); + for job in v["jobs"].as_array().unwrap_or(&vec![]) { + let jid = ident(job, "databaseId"); + let id = format!("gh:{run}:{jid}"); + let verdict = job["conclusion"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| job["status"].as_str().unwrap_or("")); + let mut n = node( + id.clone(), + Some(root.clone()), + text(job, "name"), + "job", + verdict, + text(job, "url"), + Some(format!("gh:{jid}")), + ); + n.duration = duration(job, "startedAt", "completedAt"); + nodes.push(n); + for step in job["steps"].as_array().unwrap_or(&vec![]) { + let verdict = step["conclusion"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| step["status"].as_str().unwrap_or("")); + let mut n = node( + format!("{id}:{}", ident(step, "number")), + Some(id.clone()), + text(step, "name"), + "step", + verdict, + text(job, "url"), + Some(format!("gh:{jid}")), + ); + n.duration = duration(step, "startedAt", "completedAt"); + nodes.push(n); + } + } + } + Ok(nodes) +} +fn gitlab_ci(cwd: &Path, number: u64, expected: &str) -> Result, String> { + let mr = rest( + GITLAB, + cwd, + &format!("projects/:id/merge_requests/{number}"), + None, + )?; + if text(&mr, "sha") != expected { + return Err("MR revision changed. Refresh before reviewing CI.".into()); + } + let mut pipelines = pages( + GITLAB, + cwd, + &format!("projects/:id/merge_requests/{number}/pipelines"), + )?; + pipelines.sort_by_key(|p| std::cmp::Reverse(p["id"].as_u64().unwrap_or_default())); + let merge_sha = text(&mr["head_pipeline"], "sha"); + let mut nodes = vec![]; + // Keep the newest run for each pipeline ref, not superseded attempts. + let mut refs = HashSet::new(); + for p in pipelines { + let sha = text(&p, "sha"); + let reference = text(&p, "ref"); + if sha != expected + && !(sha == merge_sha + && reference == format!("refs/merge-requests/{number}/merge") + && merge_revision_matches(GITLAB, cwd, &sha, expected)?) + { + continue; + } + let pid = ident(&p, "id"); + // Pipelines with no ref would all collide on ""; fall back to the + // pipeline id so distinct ref-less runs each survive the dedupe. + if !refs.insert(if reference.is_empty() { pid.clone() } else { reference }) { + continue; + } + let root = format!("gl:{pid}"); + nodes.push(node( + root.clone(), + None, + format!("Pipeline {pid}"), + "pipeline", + &text(&p, "status"), + text(&p, "web_url"), + None, + )); + let jobs = pages( + GITLAB, + cwd, + &format!("projects/:id/pipelines/{pid}/jobs?include_retried=false"), + )?; + let mut stages = HashSet::new(); + for j in jobs { + let stage = text(&j, "stage"); + let parent = format!("{root}:stage:{stage}"); + if stages.insert(stage.clone()) { + nodes.push(node( + parent.clone(), + Some(root.clone()), + stage, + "stage", + "", + String::new(), + None, + )); + } + let jid = ident(&j, "id"); + let mut n = node( + format!("gl:{pid}:{jid}"), + Some(parent), + text(&j, "name"), + "job", + &text(&j, "status"), + text(&j, "web_url"), + Some(format!("gl:{jid}")), + ); + n.duration = j["duration"].as_f64(); + nodes.push(n); + } + } + let stages: Vec<_> = nodes + .iter() + .filter(|n| n.kind == "stage") + .map(|n| n.id.clone()) + .collect(); + for stage in stages { + let states: Vec<_> = nodes + .iter() + .filter(|n| n.parent.as_deref() == Some(&stage)) + .map(|n| n.status.as_str()) + .collect(); + let verdict = aggregate(&states).to_string(); + if let Some(n) = nodes.iter_mut().find(|n| n.id == stage) { + n.status = verdict; + } + } + Ok(nodes) +} +fn azure_ci(cwd: &Path, number: u64, expected: &str) -> Result, String> { + let (org, project, repo) = azure_scope(cwd).map_err(|e| e.to_string())?; + let pr = cli_json( + AZURE, + cwd, + args(&[ + "repos", + "pr", + "show", + "--id", + &number.to_string(), + "--org", + &org, + "--project", + &project, + "-o", + "json", + ]), + )?; + if text(&pr["lastMergeSourceCommit"], "commitId") != expected + || !azure_pr_in_repo(&pr, &project, &repo) + { + return Err("PR identity changed. Refresh before reviewing CI.".into()); + } + let merge = text(&pr["lastMergeCommit"], "commitId"); + let repo_id = text(&pr["repository"], "id"); + let policies = cli_json( + AZURE, + cwd, + args(&[ + "repos", + "pr", + "policy", + "list", + "--id", + &number.to_string(), + "--org", + &org, + "--project", + &project, + "-o", + "json", + ]), + )?; + let mut seen = HashSet::new(); + let mut nodes = vec![]; + for policy in policies + .as_array() + .ok_or("missing Azure policy evaluations")? + { + if policy["configuration"]["isEnabled"].as_bool() == Some(false) { + continue; + } + if policy["context"]["buildIsNotCurrent"].as_bool() == Some(true) + || policy["context"]["isExpired"].as_bool() == Some(true) + { + continue; + } + let Some(build) = policy["context"]["buildId"].as_u64() else { + nodes.push(node( + format!("policy:{}", ident(policy, "evaluationId")), + None, + text(&policy["configuration"]["type"], "displayName"), + "policy", + &text(policy, "status"), + text(&pr, "url"), + None, + )); + continue; + }; + if !seen.insert(build) { + continue; + } + let b = cli_json( + AZURE, + cwd, + args(&[ + "pipelines", + "runs", + "show", + "--id", + &build.to_string(), + "--org", + &org, + "--project", + &project, + "-o", + "json", + ]), + )?; + let sha = text(&b, "sourceVersion"); + if text(&b["repository"], "id") != repo_id + || (sha != expected + && (merge.is_empty() + || sha != merge + || !merge_revision_matches(AZURE, cwd, &sha, expected)?)) + { + continue; + } + let root = format!("az:{build}"); + let url = text(&b["_links"]["web"], "href"); + let verdict = b["result"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| b["status"].as_str().unwrap_or("")); + nodes.push(node( + root.clone(), + None, + text(&b["definition"], "name"), + "pipeline", + verdict, + url.clone(), + None, + )); + let timeline = azure( + cwd, + "build", + "timeline", + &[("buildId", build.to_string())], + None, + )?; + let records = timeline["records"] + .as_array() + .ok_or("missing Azure timeline")?; + for r in records { + let id = ident(r, "id"); + let parent = text(r, "parentId"); + let verdict = r["result"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| r["state"].as_str().unwrap_or("")); + let log = r["log"]["id"].as_u64().map(|n| format!("az:{build}:{n}")); + let kind = text(r, "type").to_lowercase(); + let mut n = node( + format!("{root}:{id}"), + Some(if parent.is_empty() { + root.clone() + } else { + format!("{root}:{parent}") + }), + text(r, "name"), + &kind, + verdict, + url.clone(), + log, + ); + n.duration = duration(r, "startTime", "finishTime"); + nodes.push(n); + } + } + Ok(nodes) +} + +pub fn threads(provider: &str, cwd: &Path, number: u64) -> Result, String> { + match provider { + GITHUB => github_threads(cwd, number), + GITLAB => { + let list = pages( + GITLAB, + cwd, + &format!("projects/:id/merge_requests/{number}/discussions"), + )?; + Ok(list + .iter() + .filter_map(|d| { + let notes: Vec<_> = d["notes"] + .as_array()? + .iter() + .filter(|n| n["system"].as_bool() != Some(true)) + .collect(); + let first = *notes.first()?; + Some(ReviewThread { + id: ident(d, "id"), + reply_id: ident(first, "id"), + path: first["position"]["new_path"].as_str().map(str::to_string), + line: first["position"]["new_line"].as_u64(), + resolved: first["resolved"].as_bool(), + url: String::new(), + comments: notes + .iter() + .map(|n| ThreadComment { + id: ident(n, "id"), + author: text(&n["author"], "username"), + body: text(n, "body"), + }) + .collect(), + }) + }) + .collect()) + } + AZURE => { + let (_, _, repo) = azure_scope(cwd).map_err(|e| e.to_string())?; + let list = azure( + cwd, + "git", + "pullRequestThreads", + &[ + ("repositoryId", repo), + ("pullRequestId", number.to_string()), + ], + None, + )?; + Ok(list["value"] + .as_array() + .ok_or("missing Azure review threads")? + .iter() + .filter(|d| d["isDeleted"].as_bool() != Some(true)) + .filter_map(|d| { + let comments: Vec<_> = d["comments"] + .as_array()? + .iter() + .filter(|n| { + n["isDeleted"].as_bool() != Some(true) + && n["commentType"].as_str() != Some("system") + && n["commentType"].as_u64() != Some(3) + }) + .map(|n| ThreadComment { + id: ident(n, "id"), + author: text(&n["author"], "displayName"), + body: text(n, "content"), + }) + .filter(|n| !n.body.is_empty()) + .collect(); + if comments.is_empty() { + return None; + } + let status = d["status"].as_str().map(str::to_lowercase).or_else(|| { + d["status"].as_u64().map(|n| { + match n { + 1 => "active", + 2 => "fixed", + 3 => "wontfix", + 4 => "closed", + 5 => "bydesign", + 6 => "pending", + _ => "unknown", + } + .into() + }) + }); + Some(ReviewThread { + id: ident(d, "id"), + reply_id: comments.first().map(|c| c.id.clone()).unwrap_or_default(), + path: d["threadContext"]["filePath"].as_str().map(str::to_string), + line: d["threadContext"]["rightFileStart"]["line"].as_u64(), + resolved: status + .as_deref() + .map(|s| matches!(s, "fixed" | "closed" | "wontfix" | "bydesign")), + url: String::new(), + comments, + }) + }) + .collect()) + } + _ => Err("unsupported provider".into()), + } +} +fn github_threads(cwd: &Path, number: u64) -> Result, String> { + let repo = cli_json(GITHUB, cwd, args(&["repo", "view", "--json", "owner,name"]))?; + let owner = text(&repo["owner"], "login"); + let name = text(&repo, "name"); + let mut cursor = Value::Null; + let mut out = vec![]; + let query="query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$cursor){pageInfo{hasNextPage endCursor} nodes{id isResolved path line comments(first:100){totalCount nodes{databaseId body url author{login}}}}}}}}"; + for _ in 0..20 { + let body = json!({"query":query,"variables":{"owner":owner,"name":name,"number":number,"cursor":cursor}}); + let v = rest(GITHUB, cwd, "graphql", Some(&body))?; + if v["errors"].is_array() { + return Err(format!( + "GitHub review threads unavailable: {}", + v["errors"] + )); + } + let connection = &v["data"]["repository"]["pullRequest"]["reviewThreads"]; + for thread in connection["nodes"] + .as_array() + .ok_or("missing GitHub review threads")? + { + let comments = thread["comments"]["nodes"] + .as_array() + .ok_or("missing thread comments")?; + if thread["comments"]["totalCount"].as_u64().unwrap_or(0) > comments.len() as u64 { + return Err("Thread exceeds 100 replies. Open the full thread on GitHub.".into()); + } + let Some(first) = comments.first() else { + continue; + }; + out.push(ReviewThread { + id: text(thread, "id"), + reply_id: ident(first, "databaseId"), + path: thread["path"].as_str().map(str::to_string), + line: thread["line"].as_u64(), + resolved: thread["isResolved"].as_bool(), + url: text(first, "url"), + comments: comments + .iter() + .map(|c| ThreadComment { + id: ident(c, "databaseId"), + author: text(&c["author"], "login"), + body: text(c, "body"), + }) + .collect(), + }); + } + if connection["pageInfo"]["hasNextPage"].as_bool() != Some(true) { + let discussion = pages( + GITHUB, + cwd, + &format!("repos/{{owner}}/{{repo}}/issues/{number}/comments"), + )?; + if !discussion.is_empty() { + out.push(ReviewThread { + id: "discussion".into(), + reply_id: String::new(), + path: None, + line: None, + resolved: None, + url: String::new(), + comments: discussion + .iter() + .map(|c| ThreadComment { + id: ident(c, "id"), + author: text(&c["user"], "login"), + body: text(c, "body"), + }) + .collect(), + }); + } + return Ok(out); + } + cursor = connection["pageInfo"]["endCursor"].clone(); + } + Err("More than 2,000 threads. Open the full review on GitHub.".into()) +} + +pub fn post_reply( + provider: &str, + cwd: &Path, + number: u64, + thread: &ReviewThread, + body: &str, +) -> Result<(), String> { + match provider { + GITHUB => { + let path = if thread.id == "discussion" { + format!("repos/{{owner}}/{{repo}}/issues/{number}/comments") + } else { + format!( + "repos/{{owner}}/{{repo}}/pulls/{number}/comments/{}/replies", + thread.reply_id + ) + }; + rest(provider, cwd, &path, Some(&json!({"body":body})))?; + } + GITLAB => { + rest( + provider, + cwd, + &format!( + "projects/:id/merge_requests/{number}/discussions/{}/notes", + thread.id + ), + Some(&json!({"body":body})), + )?; + } + AZURE => { + let (_, _, repo) = azure_scope(cwd).map_err(|e| e.to_string())?; + azure( + cwd, + "git", + "pullRequestThreadComments", + &[ + ("repositoryId", repo), + ("pullRequestId", number.to_string()), + ("threadId", thread.id.clone()), + ], + Some( + &json!({"content":body,"commentType":1,"parentCommentId":thread.reply_id.parse::().map_err(|_|"Invalid reply parent")?}), + ), + )?; + } + _ => return Err("unsupported provider".into()), + } + Ok(()) +} + +pub fn log(provider: &str, cwd: &Path, id: &str) -> Result { + let p: Vec<_> = id.split(':').collect(); + if p.iter().skip(1).any(|s| s.parse::().is_err()) { + return Err("invalid log identity".into()); + } + let out = match (provider, p.as_slice()) { + (GITHUB, ["gh", job]) => checked_output( + provider, + cwd, + &args(&["run", "view", "--job", job, "--log-failed"]), + )?, + (GITLAB, ["gl", job]) => checked_output( + provider, + cwd, + &args(&["api", &format!("projects/:id/jobs/{job}/trace")]), + )?, + (AZURE, ["az", build, log]) => { + let (org, project, _) = azure_scope(cwd).map_err(|e| e.to_string())?; + checked_output( + provider, + cwd, + &args(&[ + "devops", + "invoke", + "--area", + "build", + "--resource", + "logs", + "--org", + &org, + "--api-version", + "7.1", + "--route-parameters", + &format!("project={project}"), + &format!("buildId={build}"), + &format!("logId={log}"), + "--query-parameters", + "startLine=0", + "endLine=300", + "-o", + "json", + ]), + )? + } + _ => return Err("invalid provider log".into()), + }; + let raw = String::from_utf8_lossy(&out.stdout); + let ansi = regex::Regex::new(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))") + .map_err(|e| e.to_string())?; + let raw = ansi.replace_all(&raw, ""); + let raw: String = raw + .chars() + .filter(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t')) + .collect(); + let clipped: String = raw.chars().take(32000).collect(); + Ok(if provider == AZURE || raw.chars().count() > 32000 { + format!("{clipped}\n[Excerpt truncated. Open the provider for the full log.]") + } else { + clipped + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ci_states_are_distinct() { + assert_eq!(state("CANCELLED"), "canceled"); + assert_eq!(state("ACTION_REQUIRED"), "approval"); + assert_eq!(state("notStarted"), "pending"); + assert_eq!(state("FAILURE"), "failed"); + assert_eq!(state("something-new"), "unknown"); + assert_eq!(aggregate(&["skipped"]), "skipped"); + assert_eq!(aggregate(&["passed", "canceled"]), "canceled"); + assert_eq!(aggregate(&["passed", "approval"]), "approval"); + } +} + +fn aggregate<'a>(states: &[&'a str]) -> &'a str { + for state in [ + "failed", "running", "approval", "pending", "canceled", "unknown", + ] { + if states.contains(&state) { + return state; + } + } + if !states.is_empty() && states.iter().all(|s| *s == "skipped") { + return "skipped"; + } + if !states.is_empty() && states.iter().all(|s| matches!(*s, "passed" | "skipped")) { + return "passed"; + } + "unknown" +} + +fn parent_matches(commit: &Value, expected: &str) -> bool { + commit["parents"] + .as_array() + .or_else(|| commit["parent_ids"].as_array()) + .is_some_and(|parents| { + parents + .iter() + .any(|p| p.as_str() == Some(expected) || p["sha"].as_str() == Some(expected)) + }) +} +fn github_run_matches(run: &Value, branch: &str, revisions: &[String]) -> bool { + !branch.is_empty() + && text(run, "headBranch") == branch + && revisions.contains(&text(run, "headSha")) +} +fn merge_revision_matches( + provider: &str, + cwd: &Path, + sha: &str, + expected: &str, +) -> Result { + if sha == expected { + return Ok(true); + } + if !crate::is_commit_ish(sha) { + return Ok(false); + } + let commit = match provider { + GITHUB => rest( + provider, + cwd, + &format!("repos/{{owner}}/{{repo}}/commits/{sha}"), + None, + )?, + GITLAB => rest( + provider, + cwd, + &format!("projects/:id/repository/commits/{sha}"), + None, + )?, + AZURE => { + let (_, _, repo) = azure_scope(cwd).map_err(|e| e.to_string())?; + azure( + cwd, + "git", + "commits", + &[("repositoryId", repo), ("commitId", sha.into())], + None, + )? + } + _ => return Err("Unsupported merge revision provider".into()), + }; + Ok(parent_matches(&commit, expected)) +} +#[cfg(test)] +mod revision_tests { + use super::*; + #[test] + fn workflow_on_another_branch_is_not_pr_ci_even_with_the_same_sha() { + let revisions = vec!["source".into(), "merge".into()]; + assert!(!github_run_matches( + &json!({"headBranch":"other","headSha":"source"}), + "topic", + &revisions + )); + assert!(!github_run_matches( + &json!({"headBranch":"topic","headSha":"old"}), + "topic", + &revisions + )); + assert!(github_run_matches( + &json!({"headBranch":"topic","headSha":"merge"}), + "topic", + &revisions + )); + } + #[test] + fn merge_parents_must_include_the_current_pr_source() { + assert!(parent_matches( + &json!({"parents":[{"sha":"base"},{"sha":"current"}]}), + "current" + )); + assert!(parent_matches( + &json!({"parent_ids":["base","current"]}), + "current" + )); + assert!(parent_matches( + &json!({"parents":["base","current"]}), + "current" + )); + assert!(!parent_matches( + &json!({"parents":["base","old-source"]}), + "current" + )); + assert!(!parent_matches(&json!({}), "current")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2dd4c6ee..1b678bcb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,6 +45,7 @@ mod shell_env; mod automation; mod cli_server; mod forge; +mod delivery; mod mcp_server; // Row shapes + OS-agnostic logic (subtree walk, cpu_ratio, label_for, // signal_from_name) shared by every `procmon` variant below. @@ -864,6 +865,9 @@ pub struct TaskMember { /// creation copied in. #[serde(default)] pub files_to_copy: Vec, + pub pr_url: Option, + pub pr_number: Option, + pub pr_provider: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] @@ -2610,8 +2614,24 @@ fn fetch_ref(repo: &Path, remote_ref: &str) -> std::result::Result<(), String> { /// fetch argument — a bare refname (via the remote's configured refspec) or /// a full `src:dst` refspec; `desc` names the op in error strings. fn guarded_fetch(repo: &Path, remote: &str, spec: &str, desc: &str) -> std::result::Result<(), String> { + guarded_git(repo, &["fetch", "--no-tags", remote, spec], desc, 15) +} + +/// `git push` with the same no-prompt/deadline treatment as guarded_fetch: +/// an SSH passphrase prompt or a wedged remote would otherwise block the +/// calling thread forever. +fn guarded_push(repo: &Path, args: &[&str]) -> std::result::Result<(), String> { + guarded_git(repo, args, "push", 120) +} + +fn guarded_git( + repo: &Path, + args: &[&str], + desc: &str, + timeout_secs: u64, +) -> std::result::Result<(), String> { let mut cmd = git_command(); - cmd.args(["fetch", "--no-tags", remote, spec]).current_dir(repo); + cmd.args(args).current_dir(repo); // Same login-shell env as git() so credential helpers / SSH config resolve // from a GUI-launched .app (bare launchd PATH otherwise). let (path, inject) = shell_env::spawn_env(); @@ -2645,10 +2665,9 @@ fn guarded_fetch(repo: &Path, remote: &str, spec: &str, desc: &str) -> std::resu h.and_then(|h| h.join().ok()).unwrap_or_default().trim().to_string() }; - // Poll to a hard deadline; SIGKILL on expiry. 15s covers a normal - // single-ref fetch on a slow link; ConnectTimeout=10 already caps the - // common dead-host case well under this. - let deadline = std::time::Instant::now() + Duration::from_secs(15); + // Poll to a hard deadline; SIGKILL on expiry. ConnectTimeout=10 already + // caps the common dead-host case well under the caller's timeout. + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); loop { match child.try_wait() { Ok(Some(status)) => { @@ -6033,6 +6052,9 @@ fn link_repo_mode_members(host_dir: &Path, members: &[ProjectMember], first_port let member_port = next_member_port; next_member_port = next_member_port.saturating_add(1); composition.push(TaskMember { + pr_url: None, + pr_number: None, + pr_provider: None, project_id: String::new(), repo_path: pm.root_path.clone(), dir_name, @@ -7740,6 +7762,9 @@ fn materialize_member( // clobber a file with its own stale copy at worst. The list // is still frozen so a later mode change reads the same one. Ok(TaskMember { + pr_url: None, + pr_number: None, + pr_provider: None, project_id: String::new(), repo_path: mp.root_path.clone(), dir_name: dir_name.to_string(), @@ -7801,6 +7826,9 @@ fn materialize_member( } } Ok(TaskMember { + pr_url: None, + pr_number: None, + pr_provider: None, project_id: String::new(), repo_path: mp.root_path.clone(), dir_name: dir_name.to_string(), @@ -12097,7 +12125,7 @@ enum UpdateMode { Rebase, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct UpdateResult { branch: String, /// What we updated FROM: the branch's own upstream for Pull, the task's @@ -13450,7 +13478,11 @@ fn pr_lookup_at( let by_branch = forge::pr_status(provider, cwd, None); let resolved = match by_branch { Ok(Some(pr)) => Ok(Some(pr)), - Ok(None) if known_number.is_some() => forge::pr_status(provider, cwd, known_number), + Ok(None) if known_number.is_some() => { + let branch = git(&["branch", "--show-current"], cwd).unwrap_or_default(); + forge::pr_status(provider, cwd, known_number) + .map(|pr| pr.filter(|p| !branch.trim().is_empty() && p.head == branch.trim())) + }, other => other, }; match resolved { @@ -13526,7 +13558,8 @@ pub struct MemberPrLookup { fn member_pr_lookups(id: &str) -> Result, String> { let w = load_tasks_all().into_iter().find(|w| w.id == id).ok_or("no task")?; - Ok(on_disk_members(&w) + let rows: Vec = w.composition + .iter() .map(|m| { let cwd = Path::new(&m.path); // Frozen `m.branch` is the create-time value; read HEAD like @@ -13541,10 +13574,49 @@ fn member_pr_lookups(id: &str) -> Result, String> { MemberPrLookup { dir_name: m.dir_name.clone(), branch, - lookup: pr_lookup_at(cwd, None, None), + lookup: if !cwd.is_dir() { + PrLookup { + provider: None, + remote_url: String::new(), + status: "checkout-missing".into(), + message: "Repository checkout is missing".into(), + pr: None, + } + } else { + // Persisted identity as the by-number fallback — same + // contract the host card's pr_lookup_at has. + pr_lookup_at(cwd, m.pr_number, m.pr_provider.as_deref()) + }, } }) - .collect()) + .collect(); + // Persist the identity discovered by-branch so later lookups and the + // delivery panel's selectors can fall back to it. Reload the task first: + // composition may have been edited since `w` was read at the top. + let mut latest = load_tasks_all().into_iter().find(|t| t.id == id).ok_or("no task")?; + let mut changed = false; + for row in &rows { + let Some(pr) = &row.lookup.pr else { continue }; + let Some(member) = latest.composition.iter_mut().find(|m| { + m.dir_name == row.dir_name + && w.composition.iter().any(|old| old.dir_name == m.dir_name && old.path == m.path) + }) else { + continue; + }; + if member.pr_number != Some(pr.number) + || member.pr_provider.as_deref() != Some(pr.provider.as_str()) + || member.pr_url.as_deref() != Some(pr.url.as_str()) + { + member.pr_number = Some(pr.number); + member.pr_provider = Some(pr.provider.clone()); + member.pr_url = Some(pr.url.clone()); + changed = true; + } + } + if changed { + save_task(&latest).map_err(|e| e.to_string())?; + } + Ok(rows) } /// One issue-list round-trip: provider resolution + the open issues, or @@ -13963,7 +14035,7 @@ fn task_set_pr_comments_seen(id: String, iso: String) -> Result<(), String> { /// set-upstream behaviour cannot differ between "Commit and Push" and pushing /// what is already committed. fn git_push(cwd: &Path) -> Result<(), String> { - if git(&["push"], cwd).is_ok() { + if guarded_push(cwd, &["push"]).is_ok() { return Ok(()); } let remote = detect_default_remote(cwd); @@ -13974,7 +14046,7 @@ fn git_push(cwd: &Path) -> Result<(), String> { if branch.is_empty() { return Err("cannot push: detached HEAD".to_string()); } - git(&["push", "-u", &remote, &branch], cwd).map_err(|e| e.to_string())?; + guarded_push(cwd, &["push", "-u", &remote, &branch])?; Ok(()) } @@ -19864,6 +19936,21 @@ fn open_path(path: String) -> Result<(), String> { /// copies of three lines is exactly the kind of thing a later refactor edits /// one of. Now they cannot disagree. fn spawn_os_open(target: &str) -> Result<(), String> { + // A `scheme:` target goes to the OS's registered handler, which for + // file:/smb:/javascript: is something other than "the browser". Only + // schemes with a known safe handler are URL-shaped input (forge pages, + // docs, the Support mailto:); absolute paths — the other legitimate + // input — have no scheme. Two+ chars before the colon so a Windows + // drive letter (C:\) is not mistaken for a scheme. + if let Some(scheme) = target + .split_once(':') + .map(|(s, _)| s) + .filter(|s| s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) && s.starts_with(|c: char| c.is_ascii_alphabetic())) + { + if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto") { + return Err(format!("refusing to open '{scheme}:…' — not a web link")); + } + } let (program, args) = open_command(std::env::consts::OS, target); crate::proc_ctl::command(program).args(&args).status().map_err(|e| e.to_string())?; Ok(()) @@ -24732,6 +24819,19 @@ pub fn run() { desktop_integration_status, desktop_integration_add, desktop_integration_remove, task_list_files_for_finder, task_match_ignored_files, task_send_diff_to_main, task_changes, task_git_status, task_git_branches, project_git_branches, project_branch_context, task_git_checkout, task_git_update, task_git_update_all, task_git_update_info, task_stage, task_unstage, task_commit, task_discard, task_git_log, task_git_refs, task_git_push, task_git_commit_files, task_git_compare, task_git_blame, task_git_commit_meta, task_git_commit_offset, + delivery::task_delivery_archive_ready, + delivery::task_delivery_results, + delivery::task_delivery_repos, + delivery::task_delivery_details, + delivery::task_delivery_validate, + delivery::task_delivery_log, + delivery::task_delivery_requests, + delivery::task_delivery_request, + delivery::task_delivery_request_status, + delivery::task_delivery_draft_save, + delivery::task_delivery_reply_post, + delivery::task_delivery_pr_create, + delivery::task_delivery_update, detect_forges, task_pr_status, task_member_pr_status, task_pr_create, task_pr_comments, task_set_pr_watch, task_set_pr_comments_seen, project_forge_issues, project_forge_provider, project_forge_prs, project_fetch_pr_branch, project_git_checkout, task_file_diff, task_file_diff_sides, task_file_read, file_read_external, clipboard_image_save, clipboard_image_capture, task_file_read_base64, task_file_fp, task_file_write, task_dir_list, task_path_stat, @@ -30364,6 +30464,9 @@ mod tests { let member = tempdir().unwrap(); let mut ws = task_with_member("api", host.path(), member.path()); let push = |ws: &mut Task, dir_name: &str, path: String| ws.composition.push(TaskMember { + pr_url: None, + pr_number: None, + pr_provider: None, dir_name: dir_name.into(), mode: MemberMode::RepoRoot, path, @@ -30402,7 +30505,7 @@ mod tests { branch: "stale".into(), ..Default::default() }, - // A member whose checkout vanished is skipped entirely. + // Missing checkouts must remain visible as delivery blockers. TaskMember { dir_name: "gone".into(), mode: MemberMode::RepoRoot, @@ -30412,7 +30515,8 @@ mod tests { ]; crate::save_task(&ws).unwrap(); let rows = member_pr_lookups("t1").unwrap(); - assert_eq!(rows.len(), 1); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].lookup.status, "checkout-missing"); assert_eq!(rows[0].dir_name, "api"); assert_eq!(rows[0].branch, "feat-live"); assert_eq!(rows[0].lookup.status, "no-remote"); diff --git a/src/components/task/DeliveryPanel.tsx b/src/components/task/DeliveryPanel.tsx new file mode 100644 index 00000000..c90632e8 --- /dev/null +++ b/src/components/task/DeliveryPanel.tsx @@ -0,0 +1,557 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { GitPullRequest, GitBranch, GitMerge, Wrench, RefreshCw, ChevronDown, ChevronRight, MoreHorizontal, Send, MessageSquare, ArrowUpRight, CircleCheck, CircleX, CircleHelp, Clock, CircleMinus, ShieldAlert, Sparkles } from "lucide-react"; +import { ChecksChip, ReviewChip, PR_STATE } from "./PrCard"; +import { Spinner } from "@/components/ui/Spinner"; +import { Tip } from "@/components/ui/Tooltip"; +import { DropdownRoot, DropdownTrigger, DropdownMenu, DropdownItem } from "@/components/ui/Dropdown"; +import { AppDialog } from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; +import { useApp } from "@/store/app"; +import { useUI } from "@/store/ui"; +import { usePr } from "@/store/pr"; +import { useDelivery } from "@/store/delivery"; +import { agentTargets, pickAgentTarget } from "@/lib/sendComments"; +import { forgeName, prRef } from "@/lib/forge"; +import { deliveryPrompt, repositoryLookup } from "@/lib/delivery"; +import { sendDeliveryMessage } from "@/lib/deliverySend"; +import * as ipc from "@/lib/ipc"; +import { Input } from "@/components/ui/Input"; +import type { Task, DeliveryRepo, DeliveryPrInput, DeliveryRequest, DeliveryDraft, QueueItem, UpdateMode, CiNode } from "@/lib/types"; + +const textProps = { spellCheck: false, autoCorrect: "off", autoCapitalize: "off", autoComplete: "off" }; +// Same border/focus tokens as ui/Input, sized down a step for panel density. +const field = "w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 text-[12.5px] text-[var(--color-fg)] outline-none transition-colors focus:border-[var(--color-accent)]"; +const fieldLabel = "flex flex-col gap-1.5 text-[11.5px] font-medium text-[var(--color-fg-dim)]"; +const alertCls = "break-words text-[11.5px] text-[var(--color-err)]"; +const action = "inline-flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[11.5px] text-[var(--color-fg-dim)] hover:bg-[var(--color-hover)] hover:text-[var(--color-fg)] disabled:opacity-40"; +const noRepos: DeliveryRepo[] = []; +const KIND: Record = { + fix: { icon: Wrench, color: "var(--color-palette-orange)" }, + replies: { icon: MessageSquare, color: "var(--color-palette-blue)" }, + prs: { icon: GitPullRequest, color: "var(--color-palette-purple)" }, + conflicts: { icon: GitMerge, color: "var(--color-warn)" }, +}; +const STATUS_COLOR: Record = { + prepared: "var(--color-fg-faint)", queued: "var(--color-accent)", sent: "var(--color-accent)", + drafted: "var(--color-ok)", posted: "var(--color-ok)", + uncertain: "var(--color-warn)", retry_ready: "var(--color-warn)", failed: "var(--color-err)", + draft: "var(--color-fg-faint)", posting: "var(--color-accent)", +}; +type Preview = { request: DeliveryRequest; text: string; target: string }; + +export function DeliveryPanel({ task }: { task: Task }) { + const { t } = useTranslation("panels"); + const entry = useDelivery(s => s.byTask[task.id]); + const pr = usePr(s => s.byTask[task.id]); + // Re-render when the fields agentTargets() reads change — extend the string + // if that selector starts using more of the tab. + useApp(s => (s.tabs[task.id] ?? []).map(tab => tab.type === "terminal" ? [tab.id, tab.ptyId, tab.title, tab.cli, tab.runTab].join(":") : "").join("|")); + useApp(s => s.agents); + const repos = entry?.repos ?? noRepos; + const [selected, setSelected] = useState([]); + const [items, setItems] = useState([]); + const [expanded, setExpanded] = useState([]); + const [logs, setLogs] = useState>({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [preview, setPreview] = useState(null); + const [prInputs, setPrInputs] = useState(null); + const [update, setUpdate] = useState(null); + const targets = agentTargets(task.id); + const chosen = repos.filter(r => selected.includes(r.dir_name) && r.identity); + // Durable 'queued' requests need a live queue item to ever send; queue + // entries die with the app, so a restart leaves zombies. Two consecutive + // refreshes without the item marks it failed (send() writes the status a + // beat before the queue item, so one sighting is not proof). + const orphanQueued = useRef>(new Set()); + const refresh = async () => { + await Promise.all([useDelivery.getState().refresh(task.id), usePr.getState().refresh(task.id, true)]); + const reqs = useDelivery.getState().byTask[task.id]?.requests ?? []; + const live = new Set((useApp.getState().tabs[task.id] ?? []) + .flatMap(tab => tab.type === "terminal" ? (tab.queue ?? []).map(q => q.delivery?.requestId ?? "") : []).filter(Boolean)); + let stale = false; + for (const r of reqs) { + if (r.status !== "queued" || live.has(r.id)) { orphanQueued.current.delete(r.id); continue; } + if (!orphanQueued.current.has(r.id)) { orphanQueued.current.add(r.id); continue; } + orphanQueued.current.delete(r.id); + stale = true; + await ipc.taskDeliveryRequestStatus(task.id, r.id, "failed", "Queue was cleared").catch(() => {}); + } + if (stale) await useDelivery.getState().refresh(task.id); + }; + useEffect(() => { + void refresh(); + const timer = window.setInterval(() => void refresh(), 60_000); + return () => window.clearInterval(timer); + // Only the mounted Delivery panel polls. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task.id]); + const same = (a: T[], b: T[]) => a.length === b.length && a.every((v, i) => v === b[i]); + useEffect(() => { + setSelected(prev => { const next = prev.filter(dir => repos.some(r => r.dir_name === dir)); return same(prev, next) ? prev : next; }); + }, [repos]); + // Details are pruned on refresh when an identity changes; the evidence + // selection, log excerpts, and expansion that reference them must go too + // or they'd act on data the store already discarded. + useEffect(() => { + const live = Object.keys(entry?.details ?? {}); + // Keys are `${dir}:${kind}:${id}`; dir names may contain ':', so match by + // prefix rather than slicing to the first colon. + const alive = (k: string) => live.some(dir => k.startsWith(dir + ":")); + setItems(prev => { const next = prev.filter(alive); return same(prev, next) ? prev : next; }); + setLogs(prev => { + const next = Object.fromEntries(Object.entries(prev).filter(([k]) => alive(k))); + return Object.keys(next).length === Object.keys(prev).length ? prev : next; + }); + setExpanded(prev => { const next = prev.filter(dir => live.includes(dir)); return same(prev, next) ? prev : next; }); + }, [entry?.details]); + + const toggle = (values: string[], key: string) => values.includes(key) ? values.filter(v => v !== key) : [...values, key]; + const run = async (work: () => Promise) => { + setBusy(true); setError(""); + try { await work(); } catch (e) { + const msg = String(e); + setError(msg); + // The error paragraph lives at the top of a scrollable panel — actions + // triggered deep in the details (per-thread fix/reply, CI rows) fail + // invisibly without a toast. Dialogs render the same error inline, so + // skip the toast while one is open. + if (!preview && !prInputs && !update) useUI.getState().pushToast(msg, "error"); + } finally { setBusy(false); } + }; + const applied = useRef>({}); + const showPrs = () => { + if (!chosen.length) return; + applied.current = {}; + setPrInputs(chosen.map(r => { + const proposed = entry?.requests.flatMap(q => q.prs).findLast(p => p.dir_name === r.dir_name); + const title = proposed?.title ?? task.name, body = proposed?.body ?? ""; + applied.current[r.dir_name] = { title, body }; + return { identity: r.identity!, title, body, base: r.base, draft: true }; + })); + }; + // A draft report that lands while the dialog is open fills only fields the + // user has not touched since the last applied draft. + useEffect(() => { + if (!prInputs) return; + const latest = new Map(); + for (const req of entry?.requests ?? []) for (const p of req.prs) latest.set(p.dir_name, { title: p.title, body: p.body }); + setPrInputs(list => { + if (!list) return list; + let dirty = false; + const next = list.map(inp => { + const p = latest.get(inp.identity.dir_name); + const ap = applied.current[inp.identity.dir_name]; + if (!p || !ap) return inp; + // Fill only fields still equal to the last applied draft — a field the + // user typed into keeps its value even when a newer report arrives. + const patch: Partial = {}; + if (inp.title === ap.title && inp.title !== p.title) patch.title = p.title; + if (inp.body === ap.body && inp.body !== p.body) patch.body = p.body; + if (patch.title === undefined && patch.body === undefined) return inp; + dirty = true; + applied.current[inp.identity.dir_name] = { title: patch.title ?? ap.title, body: patch.body ?? ap.body }; + return { ...inp, ...patch }; + }); + return dirty ? next : list; + }); + }, [entry?.requests]); // eslint-disable-line react-hooks/exhaustive-deps + const prepare = async (purpose: "fix" | "replies" | "prs" | "conflicts", scope = chosen, keys = items) => { + const drafts: DeliveryDraft[] = []; + const evidence: string[] = []; + const scopeBits: string[] = []; + let picked = 0; + const scoped = scope.filter(repo => repo.identity && (purpose === "prs" || purpose === "conflicts" || keys.some(k => k.startsWith(repo.dir_name + ":")))).map(repo => { + const detail = entry?.details[repo.dir_name]; + if (purpose === "prs" || purpose === "conflicts") scopeBits.push(repo.name); + if (purpose === "prs") return repo; + if (purpose === "conflicts") { + const outcome = entry?.results.find(r => r.dir_name === repo.dir_name); + if (!outcome?.result?.conflicted && !outcome?.result?.stash_conflicted) throw new Error(t("delivery.noConflict")); + evidence.push(JSON.stringify(outcome)); + return repo; + } + if (!detail) throw new Error(t("delivery.loadFirst")); + if (detail.ci_error && keys.some(k => k.startsWith(repo.dir_name + ":ci:"))) throw new Error(detail.ci_error); + if (detail.threads_error && keys.some(k => k.startsWith(repo.dir_name + ":review:"))) throw new Error(detail.threads_error); + // Fixable evidence is a failed CI node or an unresolved thread; replies + // may target any thread and keep selected CI as prompt context. + const threads = detail.threads.filter(thread => keys.includes(repo.dir_name + ":review:" + thread.id) && (purpose !== "fix" || thread.resolved !== true)); + const ci = detail.ci.filter(node => keys.includes(repo.dir_name + ":ci:" + node.id) && (purpose !== "fix" || node.status === "failed")); + picked += threads.length + ci.length; + const names = [...ci.map(n => n.name), ...threads.map(th => th.path ? `${th.path}${th.line ? ":" + th.line : ""}` : t("delivery.discussion"))]; + if (names.length) scopeBits.push(`${repo.name}: ${names.join(", ")}`); + evidence.push(JSON.stringify({ repository: repo.name, provider: detail.pr.provider, pr: detail.pr.url, revision: detail.revision, threads, ci, + logs: ci.map(n => ({ id: n.id, excerpt: logs[repo.dir_name + ":ci:" + n.id] ?? null })) })); + if (purpose === "replies") for (const thread of threads) drafts.push({ key: repo.dir_name + ":review:" + thread.id, dir_name: repo.dir_name, pr_number: detail.pr.number, + thread_id: thread.id, reply_id: thread.reply_id, body: "", status: "draft", error: null }); + return { ...repo, identity: { ...repo.identity!, pr_number: detail.pr.number, pr_revision: detail.revision } }; + }); + if ((purpose === "fix" && !picked) || (purpose === "replies" && !drafts.length)) throw new Error(t("delivery.selectEvidence")); + const request = await ipc.taskDeliveryRequest(task.id, scoped.map(r => r.identity!), drafts, purpose, scopeBits.join(" · ")); + const text = deliveryPrompt(scoped, evidence.join("\n\n") + "\nRequested draft keys: " + drafts.map(d => d.key).join(", "), + request.report, t(`delivery.purpose.${purpose}`)); + setPreview({ request, text, target: pickAgentTarget(task.id)?.id ?? "" }); + await useDelivery.getState().refresh(task.id); + }; + const send = async (queue: boolean) => { + if (!preview) return; + const target = agentTargets(task.id).find(tab => tab.id === preview.target); + if (!target?.ptyId) throw new Error(t("delivery.agentChanged")); + const item: QueueItem = { id: crypto.randomUUID(), text: preview.text, repeat: 1, remaining: 1, + delivery: { requestId: preview.request.id, identities: preview.request.identities, ptyId: target.ptyId } }; + await ipc.taskDeliveryValidate(task.id, preview.request.identities); + if (queue) { + const current = agentTargets(task.id).find(tab => tab.id === target.id && tab.ptyId === target.ptyId); + if (!current) throw new Error(t("delivery.agentChanged")); + await ipc.taskDeliveryRequestStatus(task.id, preview.request.id, "queued"); + // A double-queue of the same request is one entry: the drain would + // otherwise deliver it, see "sent", and still burn a send pass. + const rest = (current.queue ?? []).filter(q => q.delivery?.requestId !== item.delivery!.requestId); + useApp.getState().patchTab(task.id, target.id, { queue: [...rest, item], queueActive: true, queueKick: (current.queueKick ?? 0) + 1 }); + useUI.getState().pushToast(t("delivery.queued")); + } else if (!await sendDeliveryMessage(task.id, target.id, item)) { + await useDelivery.getState().refresh(task.id); + return; + } + setPreview(null); + await useDelivery.getState().refresh(task.id); + }; + // Closing without sending retires the 'prepared' request — otherwise every + // abandoned handoff leaks a durable row in delivery.json forever. Re-read + // the status first: a request already sent/queued elsewhere must be left + // alone. + const cancelPreview = () => { + const p = preview; + setPreview(null); + if (p) void ipc.taskDeliveryRequests(task.id).then(list => { + const cur = list.find(r => r.id === p.request.id); + if (cur?.status === "prepared") return ipc.taskDeliveryRequestStatus(task.id, cur.id, "failed"); + }).then(() => useDelivery.getState().refresh(task.id)).catch(() => {}); + }; + const changePr = (i: number, patch: Partial) => setPrInputs(list => list!.map((p, n) => n === i ? { ...p, ...patch } : p)); + const failed = repos.filter(repo => entry?.results.some(r => r.dir_name === repo.dir_name && (r.result?.conflicted || r.result?.stash_conflicted))); + // Split a `${dir}:${kind}:${id}` evidence key: match the repo prefix so dir + // names containing ':' keep working. + const parseKey = (k: string) => { + const repo = repos.find(r => k.startsWith(r.dir_name + ":")); + return repo ? { repo, rest: k.slice(repo.dir_name.length + 1) } : null; + }; + // "Send to agent" only makes sense when the selection holds something an + // agent can act on: a failed CI node or an unresolved review thread. + const canFix = items.some(k => { + const p = parseKey(k); + if (!p) return false; + const detail = entry?.details[p.repo.dir_name]; + if (p.rest.startsWith("ci:")) return detail?.ci.some(n => n.id === p.rest.slice(3) && n.status === "failed"); + if (p.rest.startsWith("review:")) return detail?.threads.some(th => th.id === p.rest.slice(7) && th.resolved !== true); + return false; + }); + const canReply = items.some(k => parseKey(k)?.rest.startsWith("review:")); + // 'prepared' lives in the send dialog; dismissed ('failed' with no error) + // is hidden. Queued, in-flight, drafted, and wedged requests stay visible + // so a send never silently disappears. + const shown = entry?.requests.filter(r => r.status !== "prepared" && (r.status !== "failed" || r.error)) ?? []; + return
+
+ {t("delivery.panelTitle")} + + + + {t("delivery.createPrs")} + setUpdate("merge")}>{t("delivery.update")} + + +
+
+
+ +
+ {(error || entry?.error || pr?.error) &&

{error || entry?.error || pr?.error}{(entry?.fetchedAt ?? 0) > 0 ? " " + t("delivery.stale") : ""}

} + {entry?.loading && !entry.fetchedAt &&

{t("shared.loading")}

} + {repos.map(repo => { + const detail = entry?.details[repo.dir_name]; + const sharedHost = !!task.is_main_checkout && !repo.dir_name; + const lookup = sharedHost && detail ? { status: "ok", message: "", pr: detail.pr } : repositoryLookup(pr, repo.dir_name); + const state = lookup?.pr ? PR_STATE[lookup.pr.state] : null; + return
+
+
+ setSelected(toggle(selected, repo.dir_name))} /> + {repo.name} + {repo.mode === "repo_root" && {t("delivery.sharedCheckout")}} + {lookup?.pr && } +
+
+ {repo.identity?.branch || t("delivery.unknown")} + {repo.dirty ? t("delivery.uncommitted") : repo.changed ? t("delivery.branchChanges") : repo.changed === false ? t("delivery.clean") : t("delivery.unknown")} +
+ {lookup?.pr && state ? <> +
{t(state.labelKey)}
+

{forgeName(lookup.pr.provider)} {prRef(lookup.pr.provider, lookup.pr.number)}{lookup.pr.title}

+ :

{lookup?.message || t("delivery.noPr")}

} + {repo.error &&

{repo.error}

} +
+ + {detail && expanded.includes(repo.dir_name) &&
+
{t("delivery.revision")}: {detail.revision.slice(0, 8)}
+
{t("delivery.ciStatus")} + {detail.ci_error &&

{detail.ci_error}

} + {!detail.ci.length && !detail.ci_error &&

{t("delivery.noCi")}

} + { + const key = repo.dir_name + ":ci:" + node.id; + const failed = node.status === "failed"; + return
+ {failed && } + {node.url && }
+ {node.log_id && failed && } + {logs[key] &&
{logs[key]}
} +
; + }} /> +
+
{t("delivery.reviewStatus")} + {detail.threads_error &&

{detail.threads_error}

} + {!detail.threads.length && !detail.threads_error &&

{t("delivery.noThreads")}

} + {detail.threads.map(thread => { + const key = repo.dir_name + ":review:" + thread.id; + return
+ +
{thread.comments.map(c =>

{c.author}

{c.body}

)}
+
+ + + + {thread.resolved !== true && } + +
+
; + })} +
+
} +
; + })} + {!!entry?.results.length &&
+
{t("delivery.results")}{failed.length > 0 && }
+ {entry.results.map(result => { + const Icon = result.error ? CircleX : result.result?.conflicted || result.result?.stash_conflicted ? ShieldAlert : CircleCheck; + const color = result.error ? "var(--color-err)" : result.result?.conflicted || result.result?.stash_conflicted ? "var(--color-warn)" : "var(--color-ok)"; + return

+ + {result.name} · {result.error || (result.result?.conflicted ? t("delivery.conflict") : result.result?.stash_conflicted ? t("delivery.stashConflict") : t("delivery.success"))} + {result.result?.stashed && " · " + t("delivery.autostash")} + {result.result?.target && " · " + result.result.target} + {result.url && } + +

; + })} + {entry.results.some(r => r.error || r.result?.conflicted || r.result?.stash_conflicted) && } +
} + {entry && !entry.loading && !shown.length && !entry.results.length &&

{t("delivery.hint")}

} + {!!shown.length &&
+ {t("delivery.requests")} + {shown.map(request => { + const kind = KIND[request.kind] ?? KIND.replies; + const KindIcon = kind.icon; + const waiting = request.status === "sent" && !request.error && + (request.kind === "prs" ? !request.prs.length : request.drafts.some(d => !d.body)); + const dismissable = request.status !== "drafted" && + (["queued", "sent", "uncertain", "failed"].includes(request.status) || !!request.error); + // Requests saved before `scope` existed fall back to repo names. + const scopeLabel = request.scope || request.identities.map(i => repos.find(r => r.dir_name === i.dir_name)?.name ?? i.dir_name).join(", "); + return
+
+ +
+ {t(`delivery.actions.${request.kind}`, { defaultValue: request.kind })} + {scopeLabel &&

{scopeLabel}

} +
+ + {dismissable && } +
+ {request.error &&

{request.error}

} + {waiting &&

{t("delivery.waitingReport")}

} + {request.prs.map(p => { + const repo = repos.find(r => r.dir_name === p.dir_name); + return
+
+ + {repo?.name ?? p.dir_name} · {p.title} + {repo?.identity && } +
+ {p.body &&
{t("delivery.body")}

{p.body}

} +
; + })} + {request.drafts.filter(d => request.status === "drafted" || d.body || d.error).map(draft => r.dir_name === draft.dir_name)} />)} +
; + })} +
} +
+ {items.length > 0 &&
+ {t("delivery.selectedItems", { count: items.length })} + + void run(() => prepare("replies"))}>{t("delivery.actions.replies")} setItems([])}>{t("delivery.clearSelection")} + + {canFix && } +
} + { if (!open && !busy) cancelPreview(); }} className="max-w-2xl" title={t("delivery.reviewSend")} description={t("delivery.scopeNotice")} + stickyFooter={preview && <> + {error &&

{error}

} +
+ + + +
+ }> + {preview &&
+ + {!targets.length &&

{t("reviewBar.noAgent")}

} +
+

{t("delivery.scope")}

+ {preview.request.identities.map(i => { + const repo = repos.find(r => r.dir_name === i.dir_name); + return

+ {repo?.name ?? i.dir_name} + {i.branch} · {i.head.slice(0, 8)}{i.pr_number != null && " · " + prRef(repositoryLookup(pr, i.dir_name)?.provider, i.pr_number) + " · " + i.pr_revision?.slice(0, 8)} +

; + })} +
+