From 8238a708bc6eb31900d6c58c1562beae796225eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:50:44 +0000 Subject: [PATCH 01/42] Add GitHub App merge queue: edge function, schema, and dashboard UI - supabase/migrations/003_merge_queue.sql: tables for github_app_installations, github_repositories, merge_queue_configs, merge_queues, merge_queue_entries, and ci_runs; RLS policies; RPCs link_github_installation and get_repo_queue_status - supabase/functions/github-webhook/: Deno edge function that handles installation, pull_request (label-triggered enqueue/dequeue/sync), and check_suite webhooks; HMAC-SHA256 signature verification; GitHub App JWT auth via RS256; parallel merge queue lanes with optimistic batching and bisect mode on CI failure - web/src/pages/integrations/github/callback.tsx: post-install OAuth callback that calls link_github_installation to bind the app to the logged-in user - web/src/pages/dashboard.tsx: integrations tab now shows GitHub App status, Install button, per-repo expandable config panels (target branch, required checks, parallel lanes, batch size, merge method, trigger label), and live queue status via get_repo_queue_status RPC - web/src/lib/constants.ts: GITHUB_APP_NAME and GITHUB_APP_INSTALL_URL constants --- .../functions/github-webhook/github-client.ts | 214 +++++ supabase/functions/github-webhook/index.ts | 102 +++ .../github-webhook/queue-processor.ts | 607 ++++++++++++++ .../github-webhook/webhook-handlers.ts | 324 ++++++++ supabase/migrations/003_merge_queue.sql | 231 ++++++ web/src/lib/constants.ts | 7 + web/src/pages/dashboard.tsx | 740 +++++++++++++++--- .../pages/integrations/github/callback.tsx | 157 ++++ 8 files changed, 2255 insertions(+), 127 deletions(-) create mode 100644 supabase/functions/github-webhook/github-client.ts create mode 100644 supabase/functions/github-webhook/index.ts create mode 100644 supabase/functions/github-webhook/queue-processor.ts create mode 100644 supabase/functions/github-webhook/webhook-handlers.ts create mode 100644 supabase/migrations/003_merge_queue.sql create mode 100644 web/src/pages/integrations/github/callback.tsx diff --git a/supabase/functions/github-webhook/github-client.ts b/supabase/functions/github-webhook/github-client.ts new file mode 100644 index 00000000..d6af16c8 --- /dev/null +++ b/supabase/functions/github-webhook/github-client.ts @@ -0,0 +1,214 @@ +// GitHub App authentication and REST API wrapper for the merge queue. +// +// Required env vars: +// GITHUB_APP_ID - numeric App ID +// GITHUB_APP_PRIVATE_KEY_BASE64 - base64-encoded PEM private key +// (GITHUB_WEBHOOK_SECRET handled by index.ts) + +function base64url(data: ArrayBuffer | string): string { + const bytes = + typeof data === "string" + ? new TextEncoder().encode(data) + : new Uint8Array(data); + let b64 = ""; + for (let i = 0; i < bytes.length; i += 3) { + const [a, b, c] = [bytes[i], bytes[i + 1], bytes[i + 2]]; + b64 += + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[ + a >> 2 + ]; + b64 += + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[ + ((a & 3) << 4) | (b >> 4) + ]; + b64 += + b !== undefined + ? "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[ + ((b & 0xf) << 2) | (c >> 6) + ] + : "="; + b64 += + c !== undefined + ? "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[ + c & 0x3f + ] + : "="; + } + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +function pemToDer(pem: string): ArrayBuffer { + const b64 = pem.replace(/-----[A-Z ]+-----/g, "").replace(/\s/g, ""); + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +async function createAppJWT(appId: string, privateKeyPem: string): Promise { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const payload = base64url( + JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId }) + ); + const signingInput = `${header}.${payload}`; + + const key = await crypto.subtle.importKey( + "pkcs8", + pemToDer(privateKeyPem), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"] + ); + + const sig = new Uint8Array( + await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + key, + new TextEncoder().encode(signingInput) + ) + ); + + return `${signingInput}.${base64url(sig.buffer)}`; +} + +export async function getInstallationToken(installationId: number): Promise { + const appId = Deno.env.get("GITHUB_APP_ID") ?? ""; + const privateKey = atob(Deno.env.get("GITHUB_APP_PRIVATE_KEY_BASE64") ?? ""); + const jwt = await createAppJWT(appId, privateKey); + + const res = await fetch( + `https://api.github.com/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "treq-merge-queue/1.0", + }, + } + ); + + if (!res.ok) { + throw new Error( + `Failed to get installation token for ${installationId}: ${res.status} ${await res.text()}` + ); + } + return (await res.json()).token; +} + +export class GitHubClient { + constructor( + private readonly token: string, + private readonly owner: string, + private readonly repo: string + ) {} + + static async forInstallation( + installationId: number, + owner: string, + repo: string + ): Promise { + const token = await getInstallationToken(installationId); + return new GitHubClient(token, owner, repo); + } + + private async request( + path: string, + method = "GET", + body?: unknown + ): Promise { + const url = `https://api.github.com/repos/${this.owner}/${this.repo}${path}`; + const res = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${this.token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "treq-merge-queue/1.0", + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (res.status === 204 || res.status === 404) return null; + if (!res.ok) { + const text = await res.text(); + throw new Error(`GitHub ${method} ${url} → ${res.status}: ${text}`); + } + return res.json() as Promise; + } + + async getBranchSha(branch: string): Promise { + const data = await this.request<{ commit: { sha: string } }>( + `/branches/${encodeURIComponent(branch)}` + ); + if (!data) throw new Error(`Branch not found: ${branch}`); + return data.commit.sha; + } + + async createBranch(name: string, sha: string): Promise { + await this.request("/git/refs", "POST", { ref: `refs/heads/${name}`, sha }); + } + + async updateBranch(name: string, sha: string): Promise { + await this.request( + `/git/refs/heads/${encodeURIComponent(name)}`, + "PATCH", + { sha, force: true } + ); + } + + async deleteBranch(name: string): Promise { + await this.request(`/git/refs/heads/${encodeURIComponent(name)}`, "DELETE"); + } + + /** Returns the new merge commit SHA, or null if HEAD is already up-to-date. */ + async mergeInto( + base: string, + head: string, + message: string + ): Promise { + const data = await this.request<{ sha: string }>("/merges", "POST", { + base, + head, + commit_message: message, + }); + return data?.sha ?? null; + } + + async getPR(prNumber: number) { + return this.request(`/pulls/${prNumber}`); + } + + async mergePR( + prNumber: number, + mergeMethod: string, + commitTitle: string + ): Promise { + await this.request(`/pulls/${prNumber}/merge`, "PUT", { + merge_method: mergeMethod, + commit_title: commitTitle, + }); + } + + async createComment(prNumber: number, body: string): Promise { + await this.request(`/issues/${prNumber}/comments`, "POST", { body }); + } + + async addLabel(prNumber: number, labels: string[]): Promise { + await this.request(`/issues/${prNumber}/labels`, "POST", { labels }); + } + + async removeLabel(prNumber: number, label: string): Promise { + try { + await this.request( + `/issues/${prNumber}/labels/${encodeURIComponent(label)}`, + "DELETE" + ); + } catch { + // Label may not exist — ignore + } + } +} diff --git a/supabase/functions/github-webhook/index.ts b/supabase/functions/github-webhook/index.ts new file mode 100644 index 00000000..b54611cd --- /dev/null +++ b/supabase/functions/github-webhook/index.ts @@ -0,0 +1,102 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { + handleInstallation, + handlePullRequest, + handleCheckSuite, +} from "./webhook-handlers.ts"; + +async function verifySignature( + secret: string, + body: string, + sigHeader: string +): Promise { + if (!sigHeader.startsWith("sha256=")) return false; + + const hex = sigHeader.slice(7); + if (hex.length % 2 !== 0) return false; + + const sigBytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + sigBytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + } + + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"] + ); + + return crypto.subtle.verify( + "HMAC", + key, + sigBytes, + new TextEncoder().encode(body) + ); +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { status: 204 }); + } + + if (req.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + const body = await req.text(); + const webhookSecret = Deno.env.get("GITHUB_WEBHOOK_SECRET") ?? ""; + const sigHeader = req.headers.get("x-hub-signature-256") ?? ""; + + if (webhookSecret) { + const valid = await verifySignature(webhookSecret, body, sigHeader); + if (!valid) { + return new Response("Unauthorized", { status: 401 }); + } + } + + let payload: Record; + try { + payload = JSON.parse(body); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + const event = req.headers.get("x-github-event") ?? ""; + + const supabase = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "" + ); + + // Return 200 immediately; process in the background so GitHub doesn't time out. + const process = async () => { + try { + switch (event) { + case "installation": + case "installation_repositories": + await handleInstallation(supabase, payload, event); + break; + case "pull_request": + await handlePullRequest(supabase, payload); + break; + case "check_suite": + await handleCheckSuite(supabase, payload); + break; + default: + // Unhandled event — no-op + } + } catch (err) { + console.error(`[github-webhook] Error handling "${event}":`, err); + } + }; + + // EdgeRuntime.waitUntil keeps the worker alive until the promise resolves. + // deno-lint-ignore no-explicit-any + (globalThis as any).EdgeRuntime?.waitUntil(process()) ?? process(); + + return new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" }, + }); +}); diff --git a/supabase/functions/github-webhook/queue-processor.ts b/supabase/functions/github-webhook/queue-processor.ts new file mode 100644 index 00000000..4e53f6f1 --- /dev/null +++ b/supabase/functions/github-webhook/queue-processor.ts @@ -0,0 +1,607 @@ +import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { GitHubClient } from "./github-client.ts"; + +interface QueueConfig { + required_checks: string[]; + max_parallel_queues: number; + batch_size: number; + merge_method: string; + queue_trigger_label: string; + delete_branch_on_merge: boolean; +} + +interface Queue { + id: string; + repo_id: number; + target_branch: string; + paused: boolean; + bisect_mode: boolean; +} + +interface QueueEntry { + id: string; + queue_id: string; + pr_number: number; + pr_sha: string; + pr_title: string | null; + pr_author: string | null; + position: number; + status: string; +} + +interface CIRun { + id: string; + queue_id: string; + entry_ids: string[]; + head_sha: string; + test_branch: string; + lane_number: number; + status: string; +} + +function testBranchName(targetBranch: string, lane: number): string { + // e.g. main → treq-queue/main/lane-1 + // releases/v2 → treq-queue/releases-v2/lane-2 + const safe = targetBranch.replace(/\//g, "-"); + return `treq-queue/${safe}/lane-${lane}`; +} + +const DEFAULT_CONFIG: QueueConfig = { + required_checks: [], + max_parallel_queues: 1, + batch_size: 5, + merge_method: "squash", + queue_trigger_label: "merge-queue", + delete_branch_on_merge: true, +}; + +async function getQueueConfig( + supabase: SupabaseClient, + repoId: number, + targetBranch: string +): Promise { + const { data } = await supabase + .from("merge_queue_configs") + .select("*") + .eq("repo_id", repoId) + .eq("target_branch", targetBranch) + .single(); + return data ?? DEFAULT_CONFIG; +} + +// Fetch installation_id + owner + name for a repo from the DB. +async function getRepoMeta( + supabase: SupabaseClient, + repoId: number +): Promise<{ installationId: number; owner: string; name: string } | null> { + const { data } = await supabase + .from("github_repositories") + .select("installation_id, owner, name") + .eq("id", repoId) + .single(); + if (!data) return null; + return { + installationId: data.installation_id, + owner: data.owner, + name: data.name, + }; +} + +// Ensure a queue row exists for this repo+branch, creating it if missing. +export async function getOrCreateQueue( + supabase: SupabaseClient, + repoId: number, + targetBranch: string +): Promise { + const { data: existing } = await supabase + .from("merge_queues") + .select("*") + .eq("repo_id", repoId) + .eq("target_branch", targetBranch) + .single(); + + if (existing) return existing as Queue; + + const { data: created, error } = await supabase + .from("merge_queues") + .insert({ repo_id: repoId, target_branch: targetBranch }) + .select() + .single(); + + if (error || !created) { + // Race — another worker inserted it first; fetch it. + const { data: retry } = await supabase + .from("merge_queues") + .select("*") + .eq("repo_id", repoId) + .eq("target_branch", targetBranch) + .single(); + return retry as Queue; + } + return created as Queue; +} + +export async function enqueueEntry( + supabase: SupabaseClient, + queueId: string, + prNumber: number, + prSha: string, + prTitle: string | null, + prAuthor: string | null +): Promise { + const { data: last } = await supabase + .from("merge_queue_entries") + .select("position") + .eq("queue_id", queueId) + .in("status", ["queued", "testing"]) + .order("position", { ascending: false }) + .limit(1) + .maybeSingle(); + + const position = (last?.position ?? 0) + 1; + + await supabase.from("merge_queue_entries").upsert( + { + queue_id: queueId, + pr_number: prNumber, + pr_sha: prSha, + pr_title: prTitle, + pr_author: prAuthor, + position, + status: "queued", + updated_at: new Date().toISOString(), + }, + { onConflict: "queue_id,pr_number" } + ); +} + +export async function dequeueEntry( + supabase: SupabaseClient, + queueId: string, + prNumber: number, + reason: string +): Promise { + const { data: entry } = await supabase + .from("merge_queue_entries") + .select("*") + .eq("queue_id", queueId) + .eq("pr_number", prNumber) + .maybeSingle(); + + if (!entry) return; + if (["merged", "failed", "dequeued"].includes(entry.status)) return; + + await supabase + .from("merge_queue_entries") + .update({ + status: "dequeued", + failure_reason: reason, + updated_at: new Date().toISOString(), + }) + .eq("id", entry.id); + + // If the entry was in a CI run, cancel that run and all higher lanes. + if (entry.status === "testing") { + const { data: ciRuns } = await supabase + .from("ci_runs") + .select("*") + .eq("queue_id", queueId) + .in("status", ["pending", "running"]) + .contains("entry_ids", [entry.id]); + + for (const ciRun of ciRuns ?? []) { + const meta = await getRepoMeta(supabase, 0); // dummy; we'll handle below + await cancelRunAndHigherLanes(supabase, null, ciRun as CIRun, queueId); + } + } +} + +async function cancelRunAndHigherLanes( + supabase: SupabaseClient, + gh: GitHubClient | null, + fromRun: CIRun, + queueId: string +): Promise { + const { data: toCancel } = await supabase + .from("ci_runs") + .select("*") + .eq("queue_id", queueId) + .gte("lane_number", fromRun.lane_number) + .in("status", ["pending", "running"]); + + for (const run of (toCancel ?? []) as CIRun[]) { + await supabase + .from("ci_runs") + .update({ status: "cancelled", completed_at: new Date().toISOString() }) + .eq("id", run.id); + + // Reset any entries that were in testing state for this run back to queued + if (run.entry_ids.length > 0) { + await supabase + .from("merge_queue_entries") + .update({ status: "queued", updated_at: new Date().toISOString() }) + .in("id", run.entry_ids) + .eq("status", "testing"); + } + + if (gh) { + try { + await gh.deleteBranch(run.test_branch); + } catch { + // Branch may not exist + } + } + } +} + +// Core queue driver — picks queued entries and starts parallel CI lanes. +export async function processQueue( + supabase: SupabaseClient, + queue: Queue, + installationId: number, + owner: string, + repo: string, + batchSizeOverride?: number +): Promise { + if (queue.paused) return; + + const config = await getQueueConfig(supabase, queue.repo_id, queue.target_branch); + const effectiveBatchSize = + batchSizeOverride ?? (queue.bisect_mode ? 1 : config.batch_size); + + // Active parallel lanes + const { data: activeLanes } = await supabase + .from("ci_runs") + .select("*") + .eq("queue_id", queue.id) + .in("status", ["pending", "running"]) + .order("lane_number", { ascending: true }); + + const lanes = (activeLanes ?? []) as CIRun[]; + if (lanes.length >= config.max_parallel_queues) return; + + // Entries currently sitting in an active lane (exclude from new batches) + const occupiedEntryIds = new Set(lanes.flatMap((l) => l.entry_ids)); + + const { data: queuedRows } = await supabase + .from("merge_queue_entries") + .select("*") + .eq("queue_id", queue.id) + .eq("status", "queued") + .order("position", { ascending: true }) + .limit(effectiveBatchSize * (config.max_parallel_queues - lanes.length)); + + const available = ((queuedRows ?? []) as QueueEntry[]).filter( + (e) => !occupiedEntryIds.has(e.id) + ); + + if (available.length === 0) { + // Nothing left; clear bisect mode if it was on + if (queue.bisect_mode) { + await supabase + .from("merge_queues") + .update({ bisect_mode: false, updated_at: new Date().toISOString() }) + .eq("id", queue.id); + } + return; + } + + const gh = await GitHubClient.forInstallation(installationId, owner, repo); + + // Start as many new lanes as we're allowed + let laneIdx = 0; + while ( + lanes.length + laneIdx < config.max_parallel_queues && + laneIdx * effectiveBatchSize < available.length + ) { + const nextLaneNumber = + lanes.length > 0 ? Math.max(...lanes.map((l) => l.lane_number)) + 1 + laneIdx : 1 + laneIdx; + + const batch = available.slice( + laneIdx * effectiveBatchSize, + (laneIdx + 1) * effectiveBatchSize + ); + + if (batch.length === 0) break; + + // Optimistic base: build on top of the highest active lane, or real branch tip + let baseSha: string; + if (lanes.length + laneIdx === 0) { + baseSha = await gh.getBranchSha(queue.target_branch); + } else { + const topLane = lanes[lanes.length - 1 + laneIdx] ?? lanes[lanes.length - 1]; + baseSha = topLane.head_sha; + } + + await startLane(supabase, gh, queue, config, nextLaneNumber, baseSha, batch); + laneIdx++; + } +} + +async function startLane( + supabase: SupabaseClient, + gh: GitHubClient, + queue: Queue, + config: QueueConfig, + laneNumber: number, + baseSha: string, + entries: QueueEntry[] +): Promise { + const testBranch = testBranchName(queue.target_branch, laneNumber); + + // Create (or reset) the test branch at the base commit + try { + await gh.createBranch(testBranch, baseSha); + } catch { + await gh.updateBranch(testBranch, baseSha); + } + + // Merge each PR's HEAD into the test branch in queue order + let headSha = baseSha; + const merged: QueueEntry[] = []; + const failed: QueueEntry[] = []; + + for (const entry of entries) { + try { + const sha = await gh.mergeInto( + testBranch, + entry.pr_sha, + `[treq] Merge PR #${entry.pr_number} into test branch` + ); + headSha = sha ?? headSha; + merged.push(entry); + } catch (err) { + console.error(`Merge conflict for PR #${entry.pr_number}:`, err); + failed.push(entry); + // Mark conflicting PR as failed immediately — no need for CI + await supabase + .from("merge_queue_entries") + .update({ + status: "failed", + failure_reason: "Merge conflict when building test batch", + updated_at: new Date().toISOString(), + }) + .eq("id", entry.id); + await gh.createComment( + entry.pr_number, + `❌ **Treq Merge Queue**: This PR has a merge conflict with another PR in the queue and has been removed. Please rebase and re-add the \`${config.queue_trigger_label}\` label.` + ); + } + } + + if (merged.length === 0) { + // All entries conflicted; nothing to test + await gh.deleteBranch(testBranch); + return; + } + + // Record the CI run + await supabase.from("ci_runs").insert({ + queue_id: queue.id, + entry_ids: merged.map((e) => e.id), + head_sha: headSha, + test_branch: testBranch, + lane_number: laneNumber, + status: "running", + }); + + // Mark entries as testing + await supabase + .from("merge_queue_entries") + .update({ status: "testing", updated_at: new Date().toISOString() }) + .in( + "id", + merged.map((e) => e.id) + ); + + // Notify PRs + const prList = merged.map((e) => `#${e.pr_number}`).join(", "); + for (const entry of merged) { + await gh.createComment( + entry.pr_number, + `🚦 **Treq Merge Queue**: Added to the queue (lane ${laneNumber}, batch with ${prList}). CI is running on \`${testBranch}\` @ \`${headSha.slice(0, 7)}\`.` + ); + } +} + +// Called when a check_suite completes on a treq-queue branch. +export async function handleCICompletion( + supabase: SupabaseClient, + ciRun: CIRun, + conclusion: string, + installationId: number, + owner: string, + repo: string +): Promise { + const { data: queueRow } = await supabase + .from("merge_queues") + .select("*") + .eq("id", ciRun.queue_id) + .single(); + + if (!queueRow) return; + + const queue = queueRow as Queue; + const config = await getQueueConfig(supabase, queue.repo_id, queue.target_branch); + const gh = await GitHubClient.forInstallation(installationId, owner, repo); + const passed = conclusion === "success"; + + await supabase + .from("ci_runs") + .update({ + status: passed ? "passed" : "failed", + conclusion, + completed_at: new Date().toISOString(), + }) + .eq("id", ciRun.id); + + if (passed) { + await onCIPassed(supabase, gh, ciRun, queue, config, installationId, owner, repo); + } else { + await onCIFailed(supabase, gh, ciRun, queue, config, installationId, owner, repo); + } +} + +async function onCIPassed( + supabase: SupabaseClient, + gh: GitHubClient, + ciRun: CIRun, + queue: Queue, + config: QueueConfig, + installationId: number, + owner: string, + repo: string +): Promise { + // Wait for any lower lanes to merge first + const { data: lowerActive } = await supabase + .from("ci_runs") + .select("id") + .eq("queue_id", queue.id) + .lt("lane_number", ciRun.lane_number) + .in("status", ["pending", "running", "passed"]); + + if ((lowerActive ?? []).length > 0) { + // Lower lane hasn't merged yet; this run is already updated to "passed" in DB. + // It will be picked up by onCIPassed when the lower lane finishes. + return; + } + + // Fetch the entries for this run in queue order + const { data: entryRows } = await supabase + .from("merge_queue_entries") + .select("*") + .in("id", ciRun.entry_ids) + .order("position", { ascending: true }); + + const entries = (entryRows ?? []) as QueueEntry[]; + + // Merge each PR into the target branch in order + for (const entry of entries) { + try { + await gh.mergePR( + entry.pr_number, + config.merge_method, + `${entry.pr_title ?? `PR #${entry.pr_number}`} (#${entry.pr_number})` + ); + await supabase + .from("merge_queue_entries") + .update({ + status: "merged", + merged_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("id", entry.id); + } catch (err) { + console.error(`Failed to merge PR #${entry.pr_number}:`, err); + await supabase + .from("merge_queue_entries") + .update({ + status: "failed", + failure_reason: `Merge failed: ${(err as Error).message}`, + updated_at: new Date().toISOString(), + }) + .eq("id", entry.id); + await gh.createComment( + entry.pr_number, + `❌ **Treq Merge Queue**: CI passed but merge failed — ${(err as Error).message}. Please check the PR and re-queue.` + ); + } + } + + // Clean up test branch + try { + await gh.deleteBranch(ciRun.test_branch); + } catch { + // ignore + } + + // If the next lane already passed CI, merge it now too (chain reaction) + const { data: nextPassed } = await supabase + .from("ci_runs") + .select("*") + .eq("queue_id", queue.id) + .eq("lane_number", ciRun.lane_number + 1) + .eq("status", "passed") + .maybeSingle(); + + if (nextPassed) { + await onCIPassed( + supabase, + gh, + nextPassed as CIRun, + queue, + config, + installationId, + owner, + repo + ); + } else { + // No more ready lanes — process the rest of the queue + const updatedQueue = { ...queue, bisect_mode: false }; + await processQueue(supabase, updatedQueue, installationId, owner, repo); + } +} + +async function onCIFailed( + supabase: SupabaseClient, + gh: GitHubClient, + ciRun: CIRun, + queue: Queue, + config: QueueConfig, + installationId: number, + owner: string, + repo: string +): Promise { + // Cancel this lane and all higher lanes (they were built optimistically on top of this one) + await cancelRunAndHigherLanes(supabase, gh, ciRun, queue.id); + + const { data: entryRows } = await supabase + .from("merge_queue_entries") + .select("*") + .in("id", ciRun.entry_ids) + .order("position", { ascending: true }); + + const entries = (entryRows ?? []) as QueueEntry[]; + + if (entries.length === 1) { + // Single PR definitively failed + const entry = entries[0]; + await supabase + .from("merge_queue_entries") + .update({ + status: "failed", + failure_reason: "CI failed", + updated_at: new Date().toISOString(), + }) + .eq("id", entry.id); + + await gh.createComment( + entry.pr_number, + `❌ **Treq Merge Queue**: CI failed for this PR. It has been removed from the queue. Fix the issue and re-add the \`${config.queue_trigger_label}\` label to re-queue.` + ); + await gh.removeLabel(entry.pr_number, config.queue_trigger_label); + + // Continue processing without the failed PR + await processQueue(supabase, queue, installationId, owner, repo); + } else { + // Multiple PRs failed together — switch to bisect mode (batch_size=1) + await supabase + .from("merge_queues") + .update({ bisect_mode: true, updated_at: new Date().toISOString() }) + .eq("id", queue.id); + + // All entries were reset to "queued" by cancelRunAndHigherLanes + for (const entry of entries) { + await gh.createComment( + entry.pr_number, + `⚠️ **Treq Merge Queue**: CI failed for the batch. Re-queuing this PR individually to find the culprit.` + ); + } + + // Start individual tests for each entry (up to max_parallel_queues at once) + const bisectQueue = { ...queue, bisect_mode: true }; + for (let i = 0; i < config.max_parallel_queues; i++) { + await processQueue(supabase, bisectQueue, installationId, owner, repo, 1); + } + } +} diff --git a/supabase/functions/github-webhook/webhook-handlers.ts b/supabase/functions/github-webhook/webhook-handlers.ts new file mode 100644 index 00000000..3fe3db00 --- /dev/null +++ b/supabase/functions/github-webhook/webhook-handlers.ts @@ -0,0 +1,324 @@ +import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { GitHubClient } from "./github-client.ts"; +import { + enqueueEntry, + dequeueEntry, + getOrCreateQueue, + handleCICompletion, + processQueue, +} from "./queue-processor.ts"; + +// ── installation / installation_repositories ───────────────────────────────── + +export async function handleInstallation( + supabase: SupabaseClient, + // deno-lint-ignore no-explicit-any + payload: Record, + event: string +): Promise { + const installation = payload.installation; + const action = payload.action as string; + + if (event === "installation") { + if (action === "deleted" || action === "unsuspend") { + if (action === "deleted") { + await supabase + .from("github_app_installations") + .delete() + .eq("id", installation.id); + return; + } + if (action === "unsuspend") { + await supabase + .from("github_app_installations") + .update({ suspended_at: null, updated_at: new Date().toISOString() }) + .eq("id", installation.id); + return; + } + } + + if (action === "suspend") { + await supabase + .from("github_app_installations") + .update({ + suspended_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("id", installation.id); + return; + } + + // created / new_permissions_accepted + await supabase.from("github_app_installations").upsert( + { + id: installation.id, + account_login: installation.account.login, + account_type: installation.account.type, + account_avatar_url: installation.account.avatar_url ?? null, + app_id: installation.app_id, + updated_at: new Date().toISOString(), + }, + { onConflict: "id" } + ); + + // Upsert repos included in the installation event + const repos = payload.repositories ?? []; + await upsertRepos(supabase, installation.id, repos); + } + + if (event === "installation_repositories") { + const added = payload.repositories_added ?? []; + const removed = payload.repositories_removed ?? []; + + await upsertRepos(supabase, installation.id, added); + + for (const r of removed as { id: number }[]) { + await supabase + .from("github_repositories") + .delete() + .eq("id", r.id); + } + } +} + +async function upsertRepos( + supabase: SupabaseClient, + installationId: number, + // deno-lint-ignore no-explicit-any + repos: any[] +): Promise { + if (repos.length === 0) return; + await supabase.from("github_repositories").upsert( + repos.map((r) => ({ + id: r.id, + installation_id: installationId, + owner: r.full_name.split("/")[0], + name: r.name, + full_name: r.full_name, + private: r.private ?? false, + default_branch: r.default_branch ?? "main", + updated_at: new Date().toISOString(), + })), + { onConflict: "id" } + ); +} + +// ── pull_request ────────────────────────────────────────────────────────────── + +export async function handlePullRequest( + supabase: SupabaseClient, + // deno-lint-ignore no-explicit-any + payload: Record +): Promise { + const action = payload.action as string; + const pr = payload.pull_request; + const repoPayload = payload.repository; + const installationId: number = payload.installation?.id; + + if (!installationId) return; + + // Resolve repo in our DB + const { data: repoRow } = await supabase + .from("github_repositories") + .select("id, default_branch") + .eq("id", repoPayload.id) + .maybeSingle(); + + if (!repoRow) return; // repo not tracked + + // Determine target branch (base ref of the PR) + const targetBranch: string = pr.base.ref; + + // Get queue config to know the trigger label + const { data: configRow } = await supabase + .from("merge_queue_configs") + .select("queue_trigger_label, enabled") + .eq("repo_id", repoRow.id) + .eq("target_branch", targetBranch) + .maybeSingle(); + + // Default trigger label if no config exists yet + const triggerLabel: string = configRow?.queue_trigger_label ?? "merge-queue"; + const queueEnabled: boolean = configRow?.enabled ?? true; + + if (!queueEnabled) return; + + const labelName: string | undefined = + action === "labeled" || action === "unlabeled" + ? payload.label?.name + : undefined; + + if (action === "labeled" && labelName === triggerLabel) { + // Enqueue the PR + const queue = await getOrCreateQueue(supabase, repoRow.id, targetBranch); + await enqueueEntry( + supabase, + queue.id, + pr.number, + pr.head.sha, + pr.title, + pr.user?.login ?? null + ); + await processQueue(supabase, queue, installationId, repoPayload.owner.login, repoPayload.name); + return; + } + + if ( + (action === "unlabeled" && labelName === triggerLabel) || + action === "closed" + ) { + // Dequeue the PR + const { data: queueRow } = await supabase + .from("merge_queues") + .select("id, paused, bisect_mode") + .eq("repo_id", repoRow.id) + .eq("target_branch", targetBranch) + .maybeSingle(); + + if (!queueRow) return; + + await dequeueEntry( + supabase, + queueRow.id, + pr.number, + action === "closed" + ? pr.merged + ? "PR merged outside the queue" + : "PR closed" + : "Label removed" + ); + return; + } + + if (action === "synchronize") { + // PR's HEAD changed while in the queue — update SHA and restart CI for its lane + const { data: queueRow } = await supabase + .from("merge_queues") + .select("id, paused, bisect_mode") + .eq("repo_id", repoRow.id) + .eq("target_branch", targetBranch) + .maybeSingle(); + + if (!queueRow) return; + + const { data: entryRow } = await supabase + .from("merge_queue_entries") + .select("id, status") + .eq("queue_id", queueRow.id) + .eq("pr_number", pr.number) + .maybeSingle(); + + if (!entryRow) return; + if (["merged", "failed", "dequeued"].includes(entryRow.status)) return; + + // Update SHA + await supabase + .from("merge_queue_entries") + .update({ pr_sha: pr.head.sha, updated_at: new Date().toISOString() }) + .eq("id", entryRow.id); + + if (entryRow.status === "testing") { + // Cancel the CI run containing this entry and restart + const { data: ciRuns } = await supabase + .from("ci_runs") + .select("*") + .eq("queue_id", queueRow.id) + .in("status", ["pending", "running"]) + .contains("entry_ids", [entryRow.id]); + + const gh = await GitHubClient.forInstallation( + installationId, + repoPayload.owner.login, + repoPayload.name + ); + + for (const run of ciRuns ?? []) { + // cancelRunAndHigherLanes is not exported; we replicate the logic inline + const { data: toCancel } = await supabase + .from("ci_runs") + .select("*") + .eq("queue_id", queueRow.id) + .gte("lane_number", run.lane_number) + .in("status", ["pending", "running"]); + + for (const r of toCancel ?? []) { + await supabase + .from("ci_runs") + .update({ status: "cancelled", completed_at: new Date().toISOString() }) + .eq("id", r.id); + await supabase + .from("merge_queue_entries") + .update({ status: "queued", updated_at: new Date().toISOString() }) + .in("id", r.entry_ids) + .eq("status", "testing"); + try { + await gh.deleteBranch(r.test_branch); + } catch { + // ignore + } + } + } + + await processQueue( + supabase, + queueRow, + installationId, + repoPayload.owner.login, + repoPayload.name + ); + } + } +} + +// ── check_suite ─────────────────────────────────────────────────────────────── + +export async function handleCheckSuite( + supabase: SupabaseClient, + // deno-lint-ignore no-explicit-any + payload: Record +): Promise { + const action = payload.action as string; + if (action !== "completed") return; + + const suite = payload.check_suite; + const headSha: string = suite.head_sha; + const headBranch: string | null = suite.head_branch; + const conclusion: string | null = suite.conclusion; + const installationId: number = payload.installation?.id; + + if (!installationId) return; + if (!conclusion) return; // null means still in progress + + // Only handle our test branches + if (!headBranch?.startsWith("treq-queue/")) return; + + // Look up the CI run by head SHA + const { data: ciRun } = await supabase + .from("ci_runs") + .select("*") + .eq("head_sha", headSha) + .eq("status", "running") + .maybeSingle(); + + if (!ciRun) return; + + const repoPayload = payload.repository; + const owner: string = repoPayload.owner.login; + const repo: string = repoPayload.name; + + // Map GitHub conclusions to pass/fail + // GitHub conclusions: success | failure | neutral | cancelled | skipped | timed_out | action_required + const normalizedConclusion = + conclusion === "success" || conclusion === "neutral" || conclusion === "skipped" + ? "success" + : conclusion; + + await handleCICompletion( + supabase, + ciRun, + normalizedConclusion, + installationId, + owner, + repo + ); +} diff --git a/supabase/migrations/003_merge_queue.sql b/supabase/migrations/003_merge_queue.sql new file mode 100644 index 00000000..d2c9abb2 --- /dev/null +++ b/supabase/migrations/003_merge_queue.sql @@ -0,0 +1,231 @@ +-- GitHub App installations (one per org/user that installs the app) +create table public.github_app_installations ( + id bigint primary key, -- GitHub's installation_id + account_login text not null, + account_type text not null check (account_type in ('User', 'Organization')), + account_avatar_url text, + app_id int not null, + linked_user_id uuid references auth.users(id) on delete set null, + suspended_at timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +alter table public.github_app_installations enable row level security; + +create policy "Users can view own installations" + on public.github_app_installations for select + using (linked_user_id = auth.uid()); + +-- Repos accessible under each installation +create table public.github_repositories ( + id bigint primary key, -- GitHub's repo_id + installation_id bigint references public.github_app_installations(id) on delete cascade not null, + owner text not null, + name text not null, + full_name text not null, + private boolean default false, + default_branch text default 'main', + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +alter table public.github_repositories enable row level security; + +create policy "Users can view repos of own installations" + on public.github_repositories for select + using ( + exists ( + select 1 from public.github_app_installations i + where i.id = installation_id and i.linked_user_id = auth.uid() + ) + ); + +-- Per-repo merge queue configuration (one per target branch per repo) +create table public.merge_queue_configs ( + id uuid primary key default gen_random_uuid(), + repo_id bigint references public.github_repositories(id) on delete cascade not null, + target_branch text not null default 'main', + required_checks text[] not null default array[]::text[], + max_parallel_queues int not null default 1 check (max_parallel_queues between 1 and 10), + batch_size int not null default 5 check (batch_size between 1 and 20), + merge_method text not null default 'squash' check (merge_method in ('merge', 'squash', 'rebase')), + queue_trigger_label text not null default 'merge-queue', + delete_branch_on_merge boolean not null default true, + enabled boolean not null default true, + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique(repo_id, target_branch) +); + +alter table public.merge_queue_configs enable row level security; + +create policy "Users can manage configs of own repos" + on public.merge_queue_configs for all + using ( + exists ( + select 1 + from public.github_repositories r + join public.github_app_installations i on i.id = r.installation_id + where r.id = repo_id and i.linked_user_id = auth.uid() + ) + ); + +-- Active queues (one per repo × target branch) +create table public.merge_queues ( + id uuid primary key default gen_random_uuid(), + repo_id bigint references public.github_repositories(id) on delete cascade not null, + target_branch text not null, + paused boolean not null default false, + -- Set to true after a batch CI failure; forces batch_size=1 until cleared + bisect_mode boolean not null default false, + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique(repo_id, target_branch) +); + +alter table public.merge_queues enable row level security; + +create policy "Users can view and manage queues of own repos" + on public.merge_queues for all + using ( + exists ( + select 1 + from public.github_repositories r + join public.github_app_installations i on i.id = r.installation_id + where r.id = repo_id and i.linked_user_id = auth.uid() + ) + ); + +-- Individual PRs in a queue +create type public.merge_queue_entry_status as enum ( + 'queued', + 'testing', + 'merging', + 'merged', + 'failed', + 'dequeued' +); + +create table public.merge_queue_entries ( + id uuid primary key default gen_random_uuid(), + queue_id uuid references public.merge_queues(id) on delete cascade not null, + pr_number int not null, + pr_sha text not null, + pr_title text, + pr_author text, + position int not null, + status public.merge_queue_entry_status not null default 'queued', + failure_reason text, + enqueued_at timestamptz default now(), + updated_at timestamptz default now(), + merged_at timestamptz, + unique(queue_id, pr_number) +); + +alter table public.merge_queue_entries enable row level security; + +create policy "Users can view entries of own queues" + on public.merge_queue_entries for select + using ( + exists ( + select 1 + from public.merge_queues q + join public.github_repositories r on r.id = q.repo_id + join public.github_app_installations i on i.id = r.installation_id + where q.id = queue_id and i.linked_user_id = auth.uid() + ) + ); + +-- CI batch runs triggered by the queue for each parallel lane +create type public.ci_run_status as enum ( + 'pending', + 'running', + 'passed', + 'failed', + 'cancelled' +); + +create table public.ci_runs ( + id uuid primary key default gen_random_uuid(), + queue_id uuid references public.merge_queues(id) on delete cascade not null, + entry_ids uuid[] not null default array[]::uuid[], + head_sha text not null, + test_branch text not null, + lane_number int not null default 1, + status public.ci_run_status not null default 'pending', + check_suite_id bigint, + conclusion text, + started_at timestamptz default now(), + completed_at timestamptz, + created_at timestamptz default now() +); + +alter table public.ci_runs enable row level security; + +create policy "Users can view CI runs of own queues" + on public.ci_runs for select + using ( + exists ( + select 1 + from public.merge_queues q + join public.github_repositories r on r.id = q.repo_id + join public.github_app_installations i on i.id = r.installation_id + where q.id = queue_id and i.linked_user_id = auth.uid() + ) + ); + +-- Indexes for common query patterns +create index idx_mq_entries_queue_status on public.merge_queue_entries (queue_id, status); +create index idx_mq_entries_position on public.merge_queue_entries (queue_id, position); +create index idx_ci_runs_queue_lane on public.ci_runs (queue_id, lane_number); +create index idx_ci_runs_head_sha on public.ci_runs (head_sha); +create index idx_ci_runs_check_suite on public.ci_runs (check_suite_id); +create index idx_gh_repos_full_name on public.github_repositories (full_name); +create index idx_gh_repos_installation on public.github_repositories (installation_id); + +-- RPC: link a GitHub App installation to the current user (called after OAuth callback) +create or replace function public.link_github_installation(p_installation_id bigint) +returns void +language plpgsql +security definer +as $$ +begin + update public.github_app_installations + set linked_user_id = auth.uid(), updated_at = now() + where id = p_installation_id; +end; +$$; + +-- RPC: queue status summary for the dashboard +create or replace function public.get_repo_queue_status(p_repo_id bigint) +returns table ( + queue_id uuid, + target_branch text, + queued_count bigint, + testing_count bigint, + paused boolean, + bisect_mode boolean +) +language sql +security definer +as $$ + select + q.id, + q.target_branch, + count(e.id) filter (where e.status = 'queued') as queued_count, + count(e.id) filter (where e.status = 'testing') as testing_count, + q.paused, + q.bisect_mode + from public.merge_queues q + left join public.merge_queue_entries e + on e.queue_id = q.id and e.status not in ('merged', 'failed', 'dequeued') + where q.repo_id = p_repo_id + and exists ( + select 1 + from public.github_repositories r + join public.github_app_installations i on i.id = r.installation_id + where r.id = q.repo_id and i.linked_user_id = auth.uid() + ) + group by q.id, q.target_branch, q.paused, q.bisect_mode; +$$; diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index c298059a..a1a1aaa6 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -5,3 +5,10 @@ export const PAYMENT_LINK_URL = export const APP_DEEP_LINK = "treq://"; export const APP_DOWNLOAD_URL = "/docs/getting-started/installation"; + +// GitHub App — update with your actual App slug after creating it at +// https://github.com/settings/apps/new +export const GITHUB_APP_NAME = + process.env.NODE_ENV === "production" ? "treq-merge-queue" : "treq-merge-queue-dev"; + +export const GITHUB_APP_INSTALL_URL = `https://github.com/apps/${GITHUB_APP_NAME}/installations/new`; diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 4c01d2cb..677217da 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -1,9 +1,14 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import Layout from "@theme/Layout"; import BrowserOnly from "@docusaurus/BrowserOnly"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { supabase } from "../lib/supabase"; -import { PAYMENT_LINK_URL, APP_DEEP_LINK, APP_DOWNLOAD_URL } from "../lib/constants"; +import { + PAYMENT_LINK_URL, + APP_DEEP_LINK, + APP_DOWNLOAD_URL, + GITHUB_APP_INSTALL_URL, +} from "../lib/constants"; import type { User, Session } from "@supabase/supabase-js"; interface Subscription { @@ -12,14 +17,426 @@ interface Subscription { current_period_end: string | null; } +interface GithubRepo { + id: number; + owner: string; + name: string; + full_name: string; + private: boolean; + default_branch: string; + installation_id: number; +} + +interface MergeQueueConfig { + id?: string; + repo_id: number; + target_branch: string; + required_checks: string[]; + max_parallel_queues: number; + batch_size: number; + merge_method: "merge" | "squash" | "rebase"; + queue_trigger_label: string; + delete_branch_on_merge: boolean; + enabled: boolean; +} + +interface QueueStatus { + queue_id: string; + target_branch: string; + queued_count: number; + testing_count: number; + paused: boolean; + bisect_mode: boolean; +} + type Tab = "subscription" | "integrations"; +function defaultConfig(repoId: number, defaultBranch: string): MergeQueueConfig { + return { + repo_id: repoId, + target_branch: defaultBranch, + required_checks: [], + max_parallel_queues: 1, + batch_size: 5, + merge_method: "squash", + queue_trigger_label: "merge-queue", + delete_branch_on_merge: true, + enabled: true, + }; +} + +// ── Repo config panel ──────────────────────────────────────────────────────── + +function RepoConfigPanel({ + repo, + onSave, +}: { + repo: GithubRepo; + onSave?: () => void; +}) { + const [config, setConfig] = useState( + defaultConfig(repo.id, repo.default_branch) + ); + const [queueStatus, setQueueStatus] = useState([]); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + supabase + .from("merge_queue_configs") + .select("*") + .eq("repo_id", repo.id) + .maybeSingle() + .then(({ data }) => { + if (data) setConfig(data as MergeQueueConfig); + }); + + supabase + .rpc("get_repo_queue_status", { p_repo_id: repo.id }) + .then(({ data }) => { + if (data) setQueueStatus(data as QueueStatus[]); + }); + }, [repo.id]); + + const handleSave = async () => { + setSaving(true); + await supabase.from("merge_queue_configs").upsert( + { + ...config, + updated_at: new Date().toISOString(), + }, + { onConflict: "repo_id,target_branch" } + ); + setSaving(false); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + onSave?.(); + }; + + const totalQueued = queueStatus.reduce((s, q) => s + Number(q.queued_count), 0); + const totalTesting = queueStatus.reduce((s, q) => s + Number(q.testing_count), 0); + + return ( +
+
setExpanded((e) => !e)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && setExpanded((x) => !x)} + > +
+ {repo.full_name} + {repo.private && private} +
+
+ {totalQueued > 0 && ( + + {totalQueued} queued + + )} + {totalTesting > 0 && ( + + {totalTesting} testing + + )} + {expanded ? "▲" : "▼"} +
+
+ + {expanded && ( +
+ {/* Queue status */} + {queueStatus.length > 0 && ( +
+
Active queues
+ {queueStatus.map((q) => ( +
+ → {q.target_branch} + + {q.queued_count} queued · {q.testing_count} testing + {q.paused && " · paused"} + {q.bisect_mode && " · bisecting"} + +
+ ))} +
+ )} + + {/* Config form */} +
+
+ + + setConfig((c) => ({ ...c, target_branch: e.target.value })) + } + placeholder="main" + /> +
+ +
+ + + setConfig((c) => ({ ...c, queue_trigger_label: e.target.value })) + } + placeholder="merge-queue" + /> +
+ +
+ + + setConfig((c) => ({ + ...c, + required_checks: e.target.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + })) + } + placeholder="ci / test, ci / lint" + /> +
Comma-separated check names
+
+ +
+
+ + + setConfig((c) => ({ + ...c, + max_parallel_queues: Math.max(1, Math.min(10, parseInt(e.target.value) || 1)), + })) + } + /> +
+
+ + + setConfig((c) => ({ + ...c, + batch_size: Math.max(1, Math.min(20, parseInt(e.target.value) || 1)), + })) + } + /> +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+ )} +
+ ); +} + +// ── Integrations tab ───────────────────────────────────────────────────────── + +function IntegrationsTab() { + const [repos, setRepos] = useState([]); + const [loadingRepos, setLoadingRepos] = useState(true); + + const loadRepos = useCallback(async () => { + setLoadingRepos(true); + const { data } = await supabase + .from("github_repositories") + .select("*, github_app_installations!inner(linked_user_id)") + .order("full_name"); + setRepos((data ?? []) as GithubRepo[]); + setLoadingRepos(false); + }, []); + + useEffect(() => { + loadRepos(); + }, [loadRepos]); + + const isConnected = repos.length > 0; + + return ( +
+

Integrations

+ + {/* GitHub App */} +
+
+
+
+ + + +
+
+
GitHub — Merge Queue
+
+ Automated parallel merge queues powered by Treq. +
+
+
+
+ {isConnected ? ( + Connected + ) : ( + + Install GitHub App + + )} +
+
+ + {isConnected && ( +
+
+ + {repos.length} {repos.length === 1 ? "repository" : "repositories"} + + + Manage access → + +
+ {loadingRepos ? ( +
Loading repositories…
+ ) : ( + repos.map((repo) => ( + + )) + )} +
+ )} + + {!isConnected && !loadingRepos && ( +
+
+ Install the Treq GitHub App to enable merge queues on your repositories. + Once installed, PRs labeled merge-queue are automatically + batched, tested in parallel CI lanes, and merged in order. +
+
+ )} +
+ + {/* Linear — still coming soon */} +
+
+
+
+ + + + + + +
+
+
Linear
+
+ Link issues and track progress directly from Treq. +
+
+
+ Coming Soon +
+
+
+ ); +} + +// ── Dashboard shell ────────────────────────────────────────────────────────── + function DashboardContent() { const [user, setUser] = useState(null); const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); const [subscription, setSubscription] = useState(null); - const [activeTab, setActiveTab] = useState("subscription"); + const [activeTab, setActiveTab] = useState(() => { + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + return (params.get("tab") as Tab) ?? "subscription"; + } + return "subscription"; + }); useEffect(() => { supabase.auth.getSession().then(({ data: { session: s } }) => { @@ -32,18 +449,17 @@ function DashboardContent() { setLoading(false); }); - const { data: { subscription: authSub } } = supabase.auth.onAuthStateChange((_event, s) => { + const { + data: { subscription: authSub }, + } = supabase.auth.onAuthStateChange((_event, s) => { setSession(s); setUser(s?.user ?? null); - if (!s) { - window.location.href = "/login"; - } + if (!s) window.location.href = "/login"; }); return () => authSub.unsubscribe(); }, []); - // Fetch subscription status from Stripe via FDW useEffect(() => { if (!session) return; supabase @@ -62,15 +478,14 @@ function DashboardContent() { const handleUpgrade = () => { if (!user) return; - const url = `${PAYMENT_LINK_URL}?client_reference_id=${user.id}`; - window.location.href = url; + window.location.href = `${PAYMENT_LINK_URL}?client_reference_id=${user.id}`; }; if (loading) { return (
-

Loading...

+

Loading…

); } @@ -78,8 +493,10 @@ function DashboardContent() { if (!user) return null; const avatarUrl = user.user_metadata?.avatar_url || user.user_metadata?.picture; - const fullName = user.user_metadata?.full_name || user.user_metadata?.name || "User"; - const isPro = subscription?.plan === "pro" && subscription?.status === "active"; + const fullName = + user.user_metadata?.full_name || user.user_metadata?.name || "User"; + const isPro = + subscription?.plan === "pro" && subscription?.status === "active"; return (
@@ -180,13 +597,9 @@ function DashboardContent() {
)} - {!isPro && (
-
@@ -195,52 +608,7 @@ function DashboardContent() { )} - {activeTab === "integrations" && ( -
-

Integrations

- -
-
-
-
- - - -
-
-
GitHub
-
- Connect your GitHub repositories for seamless code integration. -
-
-
- Coming Soon -
-
- -
-
-
-
- - - - - - -
-
-
Linear
-
- Link issues and track progress directly from Treq. -
-
-
- Coming Soon -
-
-
- )} + {activeTab === "integrations" && } ); @@ -249,7 +617,7 @@ function DashboardContent() { const styles: Record = { centerContainer: { display: "flex", - flexDirection: "column" as const, + flexDirection: "column", justifyContent: "center", alignItems: "center", minHeight: "60vh", @@ -271,10 +639,7 @@ const styles: Record = { gap: "2rem", minHeight: "60vh", }, - sidebar: { - width: "240px", - flexShrink: 0, - }, + sidebar: { width: "240px", flexShrink: 0 }, sidebarHeader: { display: "flex", alignItems: "center", @@ -283,11 +648,7 @@ const styles: Record = { paddingBottom: "1rem", borderBottom: "1px solid var(--ifm-color-emphasis-200)", }, - avatar: { - width: "40px", - height: "40px", - borderRadius: "50%", - }, + avatar: { width: "40px", height: "40px", borderRadius: "50%" }, avatarPlaceholder: { width: "40px", height: "40px", @@ -300,23 +661,13 @@ const styles: Record = { fontWeight: 600, fontSize: "1.1rem", }, - userName: { - fontWeight: 600, - fontSize: "0.9rem", - }, - userEmail: { - fontSize: "0.75rem", - color: "var(--ifm-color-emphasis-500)", - }, - nav: { - display: "flex", - flexDirection: "column" as const, - gap: "0.25rem", - }, + userName: { fontWeight: 600, fontSize: "0.9rem" }, + userEmail: { fontSize: "0.75rem", color: "var(--ifm-color-emphasis-500)" }, + nav: { display: "flex", flexDirection: "column", gap: "0.25rem" }, navItem: { display: "block", width: "100%", - textAlign: "left" as const, + textAlign: "left", padding: "0.5rem 0.75rem", borderRadius: "6px", border: "none", @@ -333,7 +684,7 @@ const styles: Record = { signOutButton: { display: "block", width: "100%", - textAlign: "left" as const, + textAlign: "left", padding: "0.5rem 0.75rem", borderRadius: "6px", border: "none", @@ -343,10 +694,7 @@ const styles: Record = { cursor: "pointer", marginTop: "1rem", }, - content: { - flex: 1, - minWidth: 0, - }, + content: { flex: 1, minWidth: 0 }, openAppCard: { display: "flex", alignItems: "center", @@ -357,19 +705,9 @@ const styles: Record = { backgroundColor: "var(--ifm-background-color)", marginBottom: "1.5rem", }, - openAppInfo: { - flex: 1, - minWidth: 0, - }, - openAppTitle: { - fontWeight: 600, - fontSize: "0.95rem", - marginBottom: "0.2rem", - }, - openAppDesc: { - fontSize: "0.8rem", - color: "var(--ifm-color-emphasis-500)", - }, + openAppInfo: { flex: 1, minWidth: 0 }, + openAppTitle: { fontWeight: 600, fontSize: "0.95rem", marginBottom: "0.2rem" }, + openAppDesc: { fontSize: "0.8rem", color: "var(--ifm-color-emphasis-500)" }, openAppActions: { display: "flex", alignItems: "center", @@ -381,11 +719,7 @@ const styles: Record = { color: "var(--ifm-color-emphasis-500)", textDecoration: "none", }, - sectionTitle: { - fontSize: "1.25rem", - fontWeight: 700, - marginBottom: "1rem", - }, + sectionTitle: { fontSize: "1.25rem", fontWeight: 700, marginBottom: "1rem" }, card: { padding: "1.5rem", borderRadius: "10px", @@ -404,14 +738,11 @@ const styles: Record = { color: "var(--ifm-color-emphasis-600)", fontWeight: 500, }, - fieldValue: { - fontSize: "0.9rem", - fontWeight: 500, - }, + fieldValue: { fontSize: "0.9rem", fontWeight: 500 }, proBadge: { padding: "0.2rem 0.6rem", borderRadius: "12px", - backgroundColor: "rgba(16, 185, 129, 0.1)", + backgroundColor: "rgba(16,185,129,0.1)", color: "#10b981", fontSize: "0.8rem", fontWeight: 600, @@ -429,7 +760,8 @@ const styles: Record = { padding: "0.6rem 1.5rem", borderRadius: "8px", border: "none", - background: "linear-gradient(135deg, var(--ifm-color-primary) 0%, var(--ifm-color-primary-dark) 100%)", + background: + "linear-gradient(135deg, var(--ifm-color-primary) 0%, var(--ifm-color-primary-dark) 100%)", color: "#fff", fontSize: "0.9rem", fontWeight: 600, @@ -437,6 +769,13 @@ const styles: Record = { transition: "all 0.2s", textDecoration: "none", }, + // Integrations tab + integrationHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "1rem", + }, integrationItem: { display: "flex", alignItems: "center", @@ -450,6 +789,7 @@ const styles: Record = { flex: 1, minWidth: 0, }, + integrationActions: { flexShrink: 0 }, integrationIcon: { width: "36px", height: "36px", @@ -460,14 +800,15 @@ const styles: Record = { justifyContent: "center", flexShrink: 0, }, - integrationName: { - fontWeight: 600, - fontSize: "0.9rem", - marginBottom: "0.15rem", - }, - integrationDesc: { + integrationName: { fontWeight: 600, fontSize: "0.9rem", marginBottom: "0.15rem" }, + integrationDesc: { fontSize: "0.8rem", color: "var(--ifm-color-emphasis-500)" }, + connectedBadge: { + padding: "0.2rem 0.6rem", + borderRadius: "12px", + backgroundColor: "rgba(16,185,129,0.1)", + color: "#10b981", fontSize: "0.8rem", - color: "var(--ifm-color-emphasis-500)", + fontWeight: 600, }, comingSoonBadge: { padding: "0.2rem 0.6rem", @@ -476,18 +817,163 @@ const styles: Record = { color: "var(--ifm-color-emphasis-500)", fontSize: "0.75rem", fontWeight: 500, - whiteSpace: "nowrap" as const, + whiteSpace: "nowrap", flexShrink: 0, }, + emptyState: { marginTop: "1.25rem" }, + emptyStateText: { + fontSize: "0.85rem", + color: "var(--ifm-color-emphasis-600)", + lineHeight: 1.6, + }, + repoSection: { marginTop: "1.25rem" }, + repoSectionHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "0.75rem", + }, + repoSectionTitle: { fontSize: "0.85rem", fontWeight: 600 }, + manageLink: { + fontSize: "0.8rem", + color: "var(--ifm-color-primary)", + textDecoration: "none", + }, + loadingText: { + fontSize: "0.85rem", + color: "var(--ifm-color-emphasis-500)", + padding: "0.5rem 0", + }, + // Repo config panel + repoCard: { + border: "1px solid var(--ifm-color-emphasis-200)", + borderRadius: "8px", + marginBottom: "0.5rem", + overflow: "hidden", + }, + repoHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "0.75rem 1rem", + cursor: "pointer", + userSelect: "none", + }, + repoMeta: { display: "flex", alignItems: "center", gap: "0.5rem" }, + repoName: { fontSize: "0.875rem", fontWeight: 600, fontFamily: "monospace" }, + privateBadge: { + fontSize: "0.7rem", + padding: "0.1rem 0.4rem", + borderRadius: "4px", + backgroundColor: "var(--ifm-color-emphasis-100)", + color: "var(--ifm-color-emphasis-600)", + }, + repoStats: { display: "flex", alignItems: "center", gap: "0.5rem" }, + statBadge: { + fontSize: "0.75rem", + padding: "0.15rem 0.5rem", + borderRadius: "10px", + background: "rgba(16,185,129,0.1)", + color: "#10b981", + fontWeight: 500, + }, + chevron: { fontSize: "0.7rem", color: "var(--ifm-color-emphasis-400)" }, + configBody: { + borderTop: "1px solid var(--ifm-color-emphasis-100)", + padding: "1rem", + backgroundColor: "var(--ifm-background-surface-color)", + }, + configSection: { marginBottom: "1rem" }, + configRow: { marginBottom: "0.75rem" }, + configRowInline: { + display: "flex", + gap: "1.5rem", + marginBottom: "0.75rem", + flexWrap: "wrap", + }, + configLabel: { + display: "block", + fontSize: "0.78rem", + fontWeight: 600, + color: "var(--ifm-color-emphasis-600)", + marginBottom: "0.3rem", + textTransform: "uppercase", + letterSpacing: "0.03em", + }, + input: { + width: "100%", + padding: "0.4rem 0.6rem", + borderRadius: "6px", + border: "1px solid var(--ifm-color-emphasis-200)", + backgroundColor: "var(--ifm-background-color)", + color: "var(--ifm-font-color-base)", + fontSize: "0.85rem", + outline: "none", + }, + select: { + width: "100%", + padding: "0.4rem 0.6rem", + borderRadius: "6px", + border: "1px solid var(--ifm-color-emphasis-200)", + backgroundColor: "var(--ifm-background-color)", + color: "var(--ifm-font-color-base)", + fontSize: "0.85rem", + outline: "none", + }, + inputHint: { + fontSize: "0.73rem", + color: "var(--ifm-color-emphasis-400)", + marginTop: "0.2rem", + }, + checkboxLabel: { + display: "flex", + alignItems: "center", + fontSize: "0.85rem", + cursor: "pointer", + }, + queueStatusRow: { + display: "flex", + alignItems: "center", + gap: "0.75rem", + padding: "0.4rem 0", + fontSize: "0.82rem", + }, + branchPill: { + fontFamily: "monospace", + fontSize: "0.8rem", + padding: "0.1rem 0.4rem", + borderRadius: "4px", + backgroundColor: "var(--ifm-color-emphasis-100)", + }, + queueStatusText: { color: "var(--ifm-color-emphasis-600)" }, + configActions: { display: "flex", justifyContent: "flex-end" }, + saveButton: { + padding: "0.45rem 1.2rem", + borderRadius: "6px", + border: "none", + background: + "linear-gradient(135deg, var(--ifm-color-primary) 0%, var(--ifm-color-primary-dark) 100%)", + color: "#fff", + fontSize: "0.85rem", + fontWeight: 600, + cursor: "pointer", + }, }; export default function DashboardPage(): React.ReactNode { const { siteConfig } = useDocusaurusContext(); - const flags = siteConfig.customFields?.featureFlags as { pro?: boolean } | undefined; + const flags = siteConfig.customFields?.featureFlags as + | { pro?: boolean } + | undefined; if (!flags?.pro) { return ( - {() => { window.location.href = "/"; return null; }} + + {() => { + window.location.href = "/"; + return null; + }} + ); } diff --git a/web/src/pages/integrations/github/callback.tsx b/web/src/pages/integrations/github/callback.tsx new file mode 100644 index 00000000..af3de9b3 --- /dev/null +++ b/web/src/pages/integrations/github/callback.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from "react"; +import Layout from "@theme/Layout"; +import BrowserOnly from "@docusaurus/BrowserOnly"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { supabase } from "../../../lib/supabase"; + +type State = "loading" | "success" | "error" | "unauthenticated"; + +function CallbackContent() { + const [state, setState] = useState("loading"); + const [message, setMessage] = useState(""); + + useEffect(() => { + const run = async () => { + const params = new URLSearchParams(window.location.search); + const installationId = params.get("installation_id"); + const setupAction = params.get("setup_action"); + + if (!installationId) { + setState("error"); + setMessage("No installation_id in URL."); + return; + } + + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + setState("unauthenticated"); + setMessage( + `Please sign in and then visit: /integrations/github/callback?installation_id=${installationId}&setup_action=${setupAction ?? "install"}` + ); + return; + } + + const { error } = await supabase.rpc("link_github_installation", { + p_installation_id: parseInt(installationId, 10), + }); + + if (error) { + setState("error"); + setMessage(error.message); + return; + } + + setState("success"); + + // Redirect to dashboard after a brief pause + setTimeout(() => { + window.location.href = "/dashboard?tab=integrations"; + }, 2000); + }; + + run(); + }, []); + + return ( +
+ {state === "loading" && ( + <> +
+

Connecting GitHub App…

+ + )} + + {state === "success" && ( + <> +
+

+ GitHub App connected successfully! Redirecting to your dashboard… +

+ + )} + + {state === "error" && ( + <> +
+

+ Connection failed: {message} +

+ + Return to dashboard + + + )} + + {state === "unauthenticated" && ( + <> +

You need to be signed in to link the GitHub App.

+ + Sign in + + + )} +
+ ); +} + +const styles: Record = { + container: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + minHeight: "60vh", + gap: "1rem", + }, + spinner: { + width: "32px", + height: "32px", + border: "3px solid var(--ifm-color-emphasis-200)", + borderTopColor: "var(--ifm-color-primary)", + borderRadius: "50%", + animation: "spin 0.8s linear infinite", + }, + icon: { + fontSize: "3rem", + color: "#10b981", + fontWeight: 700, + }, + text: { + fontSize: "1rem", + textAlign: "center", + maxWidth: "480px", + }, + link: { + color: "var(--ifm-color-primary)", + textDecoration: "underline", + cursor: "pointer", + }, +}; + +export default function GitHubCallbackPage(): React.ReactNode { + const { siteConfig } = useDocusaurusContext(); + const flags = siteConfig.customFields?.featureFlags as { pro?: boolean } | undefined; + + if (!flags?.pro) { + return ( + + {() => { + window.location.href = "/"; + return null; + }} + + ); + } + + return ( + + {() => } + + ); +} From d5a816992704dafe39562d1e564602d59c9dd4b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 09:21:18 +0000 Subject: [PATCH 02/42] Add merge queue integration to Tauri desktop app - Rust command `get_git_remote_url`: parses .git/config to extract GitHub owner/repo from origin remote (SSH and HTTPS forms) - `src/hooks/useMergeQueueStatus`: React Query hooks for fetching per-workspace queue status via Supabase RPC and invoking the enqueue-workspace edge function to add/remove from the queue - ShowWorkspace: "Add to Queue" / "Remove from Queue" button in the action bar; shows live queue state (position, testing, failed) - WorkspaceSidebarItem: accepts optional queueStatus prop and renders a colored dot indicator (yellow=queued, blue=testing, green=merged) - WorkspaceSidebar: fetches all active queue statuses for the repo once per 30s via get_repo_branch_queue_statuses RPC and distributes to each sidebar item - Supabase: migration 004 adds branch_name column + workspace queue RPCs; enqueue-workspace edge function applies/removes the trigger label via GitHub API; queue-processor updated to store branch_name --- src-tauri/src/commands/github.rs | 74 ++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 1 + src/components/ShowWorkspace.tsx | 58 ++++++ src/components/WorkspaceSidebar.tsx | 22 ++- src/components/WorkspaceSidebarItem.tsx | 28 ++- src/hooks/useMergeQueueStatus.ts | 67 +++++++ src/lib/api-types.ts | 27 +++ src/lib/api.ts | 4 + supabase/functions/enqueue-workspace/index.ts | 165 ++++++++++++++++++ .../github-webhook/queue-processor.ts | 4 +- .../github-webhook/webhook-handlers.ts | 3 +- supabase/migrations/004_workspace_queue.sql | 92 ++++++++++ 13 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/commands/github.rs create mode 100644 src/hooks/useMergeQueueStatus.ts create mode 100644 supabase/functions/enqueue-workspace/index.ts create mode 100644 supabase/migrations/004_workspace_queue.sql diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs new file mode 100644 index 00000000..a261a040 --- /dev/null +++ b/src-tauri/src/commands/github.rs @@ -0,0 +1,74 @@ +use std::path::Path; + +#[derive(serde::Serialize)] +pub struct GitRemoteInfo { + pub owner: String, + pub repo: String, + pub full_name: String, +} + +/// Parse owner/repo from a GitHub remote URL in either HTTPS or SSH form. +fn parse_github_remote(url: &str) -> Option { + let url = url.trim(); + + // SSH: git@github.com:owner/repo.git + if let Some(rest) = url.strip_prefix("git@github.com:") { + let path = rest.trim_end_matches(".git"); + let (owner, repo) = path.split_once('/')?; + return Some(GitRemoteInfo { + owner: owner.to_string(), + repo: repo.to_string(), + full_name: format!("{}/{}", owner, repo), + }); + } + + // HTTPS: https://github.com/owner/repo.git + for prefix in &["https://github.com/", "http://github.com/"] { + if let Some(rest) = url.strip_prefix(prefix) { + let path = rest.trim_end_matches(".git"); + let (owner, repo) = path.split_once('/')?; + return Some(GitRemoteInfo { + owner: owner.to_string(), + repo: repo.to_string(), + full_name: format!("{}/{}", owner, repo), + }); + } + } + + None +} + +/// Read the GitHub remote URL from .git/config and parse owner/repo. +/// Returns None if no GitHub remote is found. +#[tauri::command] +pub fn get_git_remote_url(repo_path: String) -> Result, String> { + let git_config_path = Path::new(&repo_path).join(".git").join("config"); + if !git_config_path.exists() { + return Ok(None); + } + + let contents = + std::fs::read_to_string(&git_config_path).map_err(|e| e.to_string())?; + + // Parse INI-style .git/config: find [remote "origin"] section and its url + let mut in_origin_remote = false; + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_origin_remote = trimmed == r#"[remote "origin"]"#; + continue; + } + if in_origin_remote { + if let Some(rest) = trimmed.strip_prefix("url") { + let rest = rest.trim_start(); + if let Some(url) = rest.strip_prefix('=') { + if let Some(info) = parse_github_remote(url.trim()) { + return Ok(Some(info)); + } + } + } + } + } + + Ok(None) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2d8db383..a8feb6f3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod commits; pub mod file_view; pub mod file_watcher; pub mod filesystem; +pub mod github; pub mod pending_review; pub mod pty_commands; pub mod session; @@ -16,6 +17,7 @@ pub use commits::*; pub use file_view::*; pub use file_watcher::*; pub use filesystem::*; +pub use github::*; pub use pending_review::*; pub use pty_commands::*; pub use session::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6bad9e5..f36d4a72 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -502,6 +502,7 @@ pub fn run() { commands::get_window_repo_path, commands::rebase_home_repo_branch, commands::dry_run_home_repo_rebase, + commands::get_git_remote_url, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/ShowWorkspace.tsx b/src/components/ShowWorkspace.tsx index f4c4cc7c..65a7b2a9 100644 --- a/src/components/ShowWorkspace.tsx +++ b/src/components/ShowWorkspace.tsx @@ -69,6 +69,7 @@ import { GitBranch, GitCommitHorizontal, GitCompareArrows, + GitMerge, Layers2, Loader2, MoreVertical, @@ -83,6 +84,7 @@ import { } from "./TargetBranchSelector"; import { TaskInput } from "./TaskInput"; import { useTerminalSettings } from "../hooks/useTerminalSettings"; +import { useEnqueueWorkspace, useMergeQueueStatus } from "../hooks/useMergeQueueStatus"; import type { SessionCreationInfo } from "../types/sessions"; interface ShowWorkspaceProps { @@ -146,6 +148,15 @@ export const ShowWorkspace = memo( const { addToast } = useToast(); const { fontSize } = useTerminalSettings(); + const { data: queueStatus } = useMergeQueueStatus( + effectiveRepoPath || undefined, + workspace?.branch_name, + ); + const { enqueue, dequeue } = useEnqueueWorkspace( + effectiveRepoPath || undefined, + workspace?.branch_name, + ); + const [changedFiles, setChangedFiles] = useState< Map >(new Map()); @@ -1502,6 +1513,53 @@ export const ShowWorkspace = memo( )} + {/* Merge queue button */} + {workspace && workspace.branch_name !== defaultBranch && !workspace.not_on_remote && ( + + + + + + + {queueStatus + ? queueStatus.status === "merged" + ? "Merged via queue" + : queueStatus.status === "testing" + ? `CI running in lane ${queueStatus.lane_number ?? "?"}` + : queueStatus.status === "failed" + ? `Failed: ${queueStatus.failure_reason ?? "unknown"}` + : `In merge queue at position ${queueStatus.position}` + : "Add this branch to the merge queue"} + + + + )} {/* Merge button moved here */} {workspace && workspace.branch_name !== defaultBranch && ( diff --git a/src/components/WorkspaceSidebar.tsx b/src/components/WorkspaceSidebar.tsx index f6280d15..6d8a909d 100644 --- a/src/components/WorkspaceSidebar.tsx +++ b/src/components/WorkspaceSidebar.tsx @@ -8,7 +8,9 @@ import { getWorkspaces, listWorkspaceStatuses, } from "../lib/api"; -import type { WorkspaceSidebarStatus } from "../lib/api-types"; +import type { WorkspaceSidebarStatus, QueueEntryStatus } from "../lib/api-types"; +import { useGitRemoteInfo } from "../hooks/useMergeQueueStatus"; +import { supabase } from "../lib/supabase"; import { buildWorkspaceTree, flattenWorkspaceTree, @@ -113,6 +115,23 @@ export const WorkspaceSidebar: React.FC = memo( placeholderData: (previousData) => previousData, }); + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + const { data: branchQueueStatuses } = useQuery({ + queryKey: ["repo-branch-queue-statuses", remoteInfo?.full_name], + queryFn: async () => { + const { data } = await supabase.rpc("get_repo_branch_queue_statuses", { + p_repo_full_name: remoteInfo!.full_name, + }); + const map = new Map(); + for (const row of (data ?? []) as { branch_name: string; status: string }[]) { + map.set(row.branch_name, row.status as QueueEntryStatus); + } + return map; + }, + enabled: !!remoteInfo, + refetchInterval: 30_000, + }); + const statuses = useMemo(() => { const statusById = new Map( (workspaceStatuses ?? []).map((status) => [status.current.id, status]), @@ -361,6 +380,7 @@ export const WorkspaceSidebar: React.FC = memo( onDeleteWorkspace={onDeleteWorkspace} onRenameWorkspace={setRenameTarget} onDoubleClick={handleDoubleClick} + queueStatus={branchQueueStatuses?.get(node.status.current.branch_name)} /> ))} {droppableProvided.placeholder} diff --git a/src/components/WorkspaceSidebarItem.tsx b/src/components/WorkspaceSidebarItem.tsx index 6ac0bbbd..95de16ba 100644 --- a/src/components/WorkspaceSidebarItem.tsx +++ b/src/components/WorkspaceSidebarItem.tsx @@ -11,7 +11,7 @@ import { Terminal, Trash2, } from "lucide-react"; -import type { Workspace } from "../lib/api"; +import type { Workspace, QueueEntryStatus } from "../lib/api"; import type { FlattenedWorkspaceNode } from "../lib/workspace-tree"; import { cn, getFullWorkspacePath } from "../lib/utils"; import { getWorkspaceTitle as getWorkspaceTitleFromUtils } from "../lib/workspace-utils"; @@ -124,6 +124,18 @@ interface WorkspaceSidebarItemProps { onDeleteWorkspace?: (workspace: Workspace) => void; onRenameWorkspace: (workspace: Workspace) => void; onDoubleClick?: (workspace: Workspace, event: React.MouseEvent) => void; + queueStatus?: QueueEntryStatus; +} + +function queueStatusDot(status: QueueEntryStatus): { color: string; label: string } { + switch (status) { + case "queued": return { color: "bg-yellow-400", label: "In merge queue" }; + case "testing": return { color: "bg-blue-400 animate-pulse", label: "CI running in merge queue" }; + case "passed": return { color: "bg-green-400", label: "Passed CI, awaiting merge" }; + case "merged": return { color: "bg-green-600", label: "Merged via queue" }; + case "failed": return { color: "bg-red-500", label: "Failed in merge queue" }; + default: return { color: "bg-muted-foreground", label: status }; + } } export const WorkspaceSidebarItem: React.FC = ({ @@ -140,6 +152,7 @@ export const WorkspaceSidebarItem: React.FC = ({ onDeleteWorkspace, onRenameWorkspace, onDoubleClick, + queueStatus, }) => { const workspace = node.status.current; const isSelected = @@ -204,6 +217,19 @@ export const WorkspaceSidebarItem: React.FC = ({ aria-label="Conflicted workspace" /> )} + {queueStatus && ( + + + + + + {queueStatusDot(queueStatus).label} + + + )}
getGitRemoteUrl(repoPath!), + enabled: !!repoPath, + staleTime: 5 * 60 * 1000, // remote URL rarely changes + }); +} + +export function useMergeQueueStatus( + repoPath: string | undefined, + branchName: string | undefined, +) { + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + + return useQuery({ + queryKey: ["merge-queue-status", remoteInfo?.full_name, branchName], + queryFn: async () => { + if (!remoteInfo || !branchName) return null; + const { data, error } = await supabase.rpc("get_workspace_queue_status", { + p_repo_full_name: remoteInfo.full_name, + p_branch_name: branchName, + }); + if (error) throw error; + return (data as WorkspaceQueueStatus[] | null)?.[0] ?? null; + }, + enabled: !!remoteInfo && !!branchName, + refetchInterval: 30_000, + }); +} + +export function useEnqueueWorkspace( + repoPath: string | undefined, + branchName: string | undefined, +) { + const queryClient = useQueryClient(); + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + + const mutate = useCallback( + async (action: "enqueue" | "dequeue") => { + if (!remoteInfo || !branchName) throw new Error("Repository or branch not detected"); + const { error } = await supabase.functions.invoke("enqueue-workspace", { + body: { + repo_full_name: remoteInfo.full_name, + branch_name: branchName, + action, + }, + }); + if (error) throw error; + await queryClient.invalidateQueries({ + queryKey: ["merge-queue-status", remoteInfo.full_name, branchName], + }); + }, + [remoteInfo, branchName, queryClient], + ); + + const enqueue = useMutation({ mutationFn: () => mutate("enqueue") }); + const dequeue = useMutation({ mutationFn: () => mutate("dequeue") }); + + return { enqueue, dequeue, remoteInfo }; +} diff --git a/src/lib/api-types.ts b/src/lib/api-types.ts index 85e2e3f2..734e66b0 100644 --- a/src/lib/api-types.ts +++ b/src/lib/api-types.ts @@ -321,6 +321,33 @@ export interface PendingReview { updated_at: string; } +export interface GitRemoteInfo { + owner: string; + repo: string; + full_name: string; +} + +export type QueueEntryStatus = + | "queued" + | "testing" + | "passed" + | "merged" + | "failed" + | "dequeued"; + +export interface WorkspaceQueueStatus { + entry_id: string; + pr_number: number; + status: QueueEntryStatus; + position: number; + target_branch: string; + lane_number: number | null; + ci_run_status: string | null; + failure_reason: string | null; + enqueued_at: string; + merged_at: string | null; +} + export type ConflictStyle = | "jj_diff" | "jj_snapshot" diff --git a/src/lib/api.ts b/src/lib/api.ts index da7fca02..898c4d2d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,6 +2,7 @@ import type { BranchStatus, DirectoryEntry, EditorAppsResponse, + GitRemoteInfo, HomeRebaseDryRunResult, JjBranch, JjCommitsAhead, @@ -101,6 +102,9 @@ export const getWindowRepoPath = (): Promise => export const detectEditorApps = (): Promise => invoke("detect_editor_apps"); +export const getGitRemoteUrl = (repoPath: string): Promise => + invoke("get_git_remote_url", { repoPath }); + // JJ Workspace API // JJ Diff API export const getWorkspaceChangedFiles = ( diff --git a/supabase/functions/enqueue-workspace/index.ts b/supabase/functions/enqueue-workspace/index.ts new file mode 100644 index 00000000..0df212a6 --- /dev/null +++ b/supabase/functions/enqueue-workspace/index.ts @@ -0,0 +1,165 @@ +// Edge function: add or remove a workspace from the merge queue by adding / +// removing the queue trigger label on its GitHub PR. +// +// POST body: { repo_full_name: string; branch_name: string; action: "enqueue" | "dequeue" } +// Auth: user JWT in Authorization header (passed automatically by supabase.functions.invoke) + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { getInstallationToken } from "../github-webhook/github-client.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders, status: 204 }); + } + if (req.method !== "POST") { + return json({ error: "Method not allowed" }, 405); + } + + // Authenticate the caller + const authHeader = req.headers.get("authorization") ?? ""; + const userToken = authHeader.replace(/^Bearer\s+/i, ""); + if (!userToken) return json({ error: "Unauthorized" }, 401); + + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; + const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + + // Verify user identity via their JWT + const supabaseUser = createClient(supabaseUrl, supabaseAnonKey, { + global: { headers: { Authorization: `Bearer ${userToken}` } }, + }); + const { + data: { user }, + error: authError, + } = await supabaseUser.auth.getUser(); + if (authError || !user) return json({ error: "Unauthorized" }, 401); + + // Parse request body + let body: { repo_full_name?: string; branch_name?: string; action?: string }; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON" }, 400); + } + + const { repo_full_name, branch_name, action } = body; + if (!repo_full_name || !branch_name || !action) { + return json({ error: "Missing required fields: repo_full_name, branch_name, action" }, 400); + } + if (action !== "enqueue" && action !== "dequeue") { + return json({ error: "action must be 'enqueue' or 'dequeue'" }, 400); + } + + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + // Look up the repo + installation, verifying the user owns it + const { data: repoRow, error: repoErr } = await supabase + .from("github_repositories") + .select("id, owner, name, installation_id") + .eq("full_name", repo_full_name) + .maybeSingle(); + + if (repoErr || !repoRow) { + return json({ error: "Repository not found or not connected" }, 404); + } + + // Verify the caller owns this installation + const { data: instRow } = await supabase + .from("github_app_installations") + .select("linked_user_id") + .eq("id", repoRow.installation_id) + .single(); + + if (!instRow || instRow.linked_user_id !== user.id) { + return json({ error: "Forbidden" }, 403); + } + + // Get the queue config to know the trigger label (fall back to "merge-queue") + const { data: configRow } = await supabase + .from("merge_queue_configs") + .select("queue_trigger_label, enabled") + .eq("repo_id", repoRow.id) + .maybeSingle(); + + if (configRow && !configRow.enabled && action === "enqueue") { + return json({ error: "Merge queue is disabled for this repository" }, 409); + } + + const triggerLabel: string = configRow?.queue_trigger_label ?? "merge-queue"; + + // Get an installation token to make GitHub API calls + let token: string; + try { + token = await getInstallationToken(repoRow.installation_id); + } catch (err) { + return json({ error: `Failed to authenticate with GitHub: ${(err as Error).message}` }, 502); + } + + const ghHeaders = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "treq-merge-queue/1.0", + }; + + const apiBase = `https://api.github.com/repos/${repoRow.owner}/${repoRow.name}`; + + // Find the open PR for this branch + const prRes = await fetch( + `${apiBase}/pulls?head=${encodeURIComponent(repoRow.owner + ":" + branch_name)}&state=open&per_page=5`, + { headers: ghHeaders } + ); + + if (!prRes.ok) { + const text = await prRes.text(); + return json({ error: `GitHub API error looking up PR: ${prRes.status} ${text}` }, 502); + } + + const prs: { number: number; head: { ref: string } }[] = await prRes.json(); + const pr = prs.find((p) => p.head.ref === branch_name); + + if (!pr) { + return json( + { error: `No open PR found for branch '${branch_name}'. Push the branch and open a PR first.` }, + 404 + ); + } + + if (action === "enqueue") { + const labelRes = await fetch(`${apiBase}/issues/${pr.number}/labels`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ labels: [triggerLabel] }), + }); + if (!labelRes.ok && labelRes.status !== 422) { + const text = await labelRes.text(); + return json({ error: `Failed to add label: ${labelRes.status} ${text}` }, 502); + } + } else { + const labelRes = await fetch( + `${apiBase}/issues/${pr.number}/labels/${encodeURIComponent(triggerLabel)}`, + { method: "DELETE", headers: ghHeaders } + ); + // 404 = label not on PR, which is fine + if (!labelRes.ok && labelRes.status !== 404) { + const text = await labelRes.text(); + return json({ error: `Failed to remove label: ${labelRes.status} ${text}` }, 502); + } + } + + return json({ ok: true, pr_number: pr.number, action }, 200); +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} diff --git a/supabase/functions/github-webhook/queue-processor.ts b/supabase/functions/github-webhook/queue-processor.ts index 4e53f6f1..590a0682 100644 --- a/supabase/functions/github-webhook/queue-processor.ts +++ b/supabase/functions/github-webhook/queue-processor.ts @@ -127,7 +127,8 @@ export async function enqueueEntry( prNumber: number, prSha: string, prTitle: string | null, - prAuthor: string | null + prAuthor: string | null, + branchName: string | null = null ): Promise { const { data: last } = await supabase .from("merge_queue_entries") @@ -147,6 +148,7 @@ export async function enqueueEntry( pr_sha: prSha, pr_title: prTitle, pr_author: prAuthor, + branch_name: branchName, position, status: "queued", updated_at: new Date().toISOString(), diff --git a/supabase/functions/github-webhook/webhook-handlers.ts b/supabase/functions/github-webhook/webhook-handlers.ts index 3fe3db00..ee9b1a9b 100644 --- a/supabase/functions/github-webhook/webhook-handlers.ts +++ b/supabase/functions/github-webhook/webhook-handlers.ts @@ -157,7 +157,8 @@ export async function handlePullRequest( pr.number, pr.head.sha, pr.title, - pr.user?.login ?? null + pr.user?.login ?? null, + pr.head.ref ?? null ); await processQueue(supabase, queue, installationId, repoPayload.owner.login, repoPayload.name); return; diff --git a/supabase/migrations/004_workspace_queue.sql b/supabase/migrations/004_workspace_queue.sql new file mode 100644 index 00000000..1a2e3963 --- /dev/null +++ b/supabase/migrations/004_workspace_queue.sql @@ -0,0 +1,92 @@ +-- Add branch_name to merge_queue_entries for workspace-side lookups +alter table public.merge_queue_entries + add column if not exists branch_name text; + +create index if not exists idx_mq_entries_branch + on public.merge_queue_entries (queue_id, branch_name); + +-- RPC: queue status for a single workspace branch (used by the desktop app) +create or replace function public.get_workspace_queue_status( + p_repo_full_name text, + p_branch_name text +) +returns table ( + entry_id uuid, + pr_number int, + status text, + position int, + target_branch text, + lane_number int, + ci_run_status text, + failure_reason text, + enqueued_at timestamptz, + merged_at timestamptz +) +language sql +security definer +as $$ + select + e.id as entry_id, + e.pr_number, + e.status::text, + e.position, + q.target_branch, + cr.lane_number, + cr.status::text as ci_run_status, + e.failure_reason, + e.enqueued_at, + e.merged_at + from public.merge_queue_entries e + join public.merge_queues q + on q.id = e.queue_id + join public.github_repositories r + on r.id = q.repo_id + join public.github_app_installations i + on i.id = r.installation_id + left join lateral ( + select c.lane_number, c.status + from public.ci_runs c + where c.queue_id = q.id + and c.entry_ids @> array[e.id] + and c.status in ('pending', 'running', 'passed') + order by c.created_at desc + limit 1 + ) cr on true + where r.full_name = p_repo_full_name + and e.branch_name = p_branch_name + and e.status::text not in ('dequeued') + and i.linked_user_id = auth.uid() + order by e.enqueued_at desc + limit 1; +$$; + +-- RPC: all active entries for a repo, indexed by branch_name (used by the sidebar) +create or replace function public.get_repo_branch_queue_statuses( + p_repo_full_name text +) +returns table ( + branch_name text, + status text, + position int, + target_branch text +) +language sql +security definer +as $$ + select + e.branch_name, + e.status::text, + e.position, + q.target_branch + from public.merge_queue_entries e + join public.merge_queues q + on q.id = e.queue_id + join public.github_repositories r + on r.id = q.repo_id + join public.github_app_installations i + on i.id = r.installation_id + where r.full_name = p_repo_full_name + and e.status::text not in ('failed', 'dequeued') + and e.branch_name is not null + and i.linked_user_id = auth.uid(); +$$; From 26ff4ea15d833e76e138edc7d664d3ee747ca71e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 17:41:16 +0000 Subject: [PATCH 03/42] fix: format, type errors, and ast-grep snapshots for CI - Fix addToast calls: use type: "success"/"error" not variant - Run biome format on all changed files - Run cargo fmt on github.rs - Generate missing ast-grep snapshots for no-e2e-* rules (these were introduced without their snapshot files and blocked CI on any PR touching src/) --- src-tauri/src/commands/github.rs | 3 +- src/components/ShowWorkspace.tsx | 118 +++++++++++------- src/components/WorkspaceSidebar.tsx | 14 ++- src/components/WorkspaceSidebarItem.tsx | 26 ++-- src/hooks/useMergeQueueStatus.ts | 3 +- src/lib/api.ts | 5 +- .../no-e2e-css-locator-snapshot.yml | 30 +++++ .../no-e2e-direct-url-goto-snapshot.yml | 23 ++++ .../no-e2e-js-evaluate-snapshot.yml | 30 +++++ .../no-e2e-query-selector-snapshot.yml | 30 +++++ .../no-e2e-url-assertion-snapshot.yml | 16 +++ .../__snapshots__/no-e2e-xpath-snapshot.yml | 23 ++++ 12 files changed, 260 insertions(+), 61 deletions(-) create mode 100644 test/ast-grep/__tests__/__snapshots__/no-e2e-css-locator-snapshot.yml create mode 100644 test/ast-grep/__tests__/__snapshots__/no-e2e-direct-url-goto-snapshot.yml create mode 100644 test/ast-grep/__tests__/__snapshots__/no-e2e-js-evaluate-snapshot.yml create mode 100644 test/ast-grep/__tests__/__snapshots__/no-e2e-query-selector-snapshot.yml create mode 100644 test/ast-grep/__tests__/__snapshots__/no-e2e-url-assertion-snapshot.yml create mode 100644 test/ast-grep/__tests__/__snapshots__/no-e2e-xpath-snapshot.yml diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs index a261a040..84968d6c 100644 --- a/src-tauri/src/commands/github.rs +++ b/src-tauri/src/commands/github.rs @@ -47,8 +47,7 @@ pub fn get_git_remote_url(repo_path: String) -> Result, St return Ok(None); } - let contents = - std::fs::read_to_string(&git_config_path).map_err(|e| e.to_string())?; + let contents = std::fs::read_to_string(&git_config_path).map_err(|e| e.to_string())?; // Parse INI-style .git/config: find [remote "origin"] section and its url let mut in_origin_remote = false; diff --git a/src/components/ShowWorkspace.tsx b/src/components/ShowWorkspace.tsx index 65a7b2a9..ba36d3fc 100644 --- a/src/components/ShowWorkspace.tsx +++ b/src/components/ShowWorkspace.tsx @@ -84,7 +84,10 @@ import { } from "./TargetBranchSelector"; import { TaskInput } from "./TaskInput"; import { useTerminalSettings } from "../hooks/useTerminalSettings"; -import { useEnqueueWorkspace, useMergeQueueStatus } from "../hooks/useMergeQueueStatus"; +import { + useEnqueueWorkspace, + useMergeQueueStatus, +} from "../hooks/useMergeQueueStatus"; import type { SessionCreationInfo } from "../types/sessions"; interface ShowWorkspaceProps { @@ -1514,52 +1517,75 @@ export const ShowWorkspace = memo( )} {/* Merge queue button */} - {workspace && workspace.branch_name !== defaultBranch && !workspace.not_on_remote && ( - - - - - - - {queueStatus - ? queueStatus.status === "merged" - ? "Merged via queue" - : queueStatus.status === "testing" - ? `CI running in lane ${queueStatus.lane_number ?? "?"}` - : queueStatus.status === "failed" - ? `Failed: ${queueStatus.failure_reason ?? "unknown"}` - : `In merge queue at position ${queueStatus.position}` - : "Add this branch to the merge queue"} - - - - )} + size="sm" + className="gap-1" + disabled={enqueue.isPending || dequeue.isPending} + onClick={async () => { + const isInQueue = + !!queueStatus && + !["merged", "failed", "dequeued"].includes( + queueStatus.status, + ); + try { + if (isInQueue) { + await dequeue.mutateAsync(); + addToast({ + title: "Removed from merge queue", + type: "success", + }); + } else { + await enqueue.mutateAsync(); + addToast({ + title: "Added to merge queue", + type: "success", + }); + } + } catch (err) { + addToast({ + title: "Queue error", + description: (err as Error).message, + type: "error", + }); + } + }} + > + + {queueStatus && + !["merged", "failed", "dequeued"].includes( + queueStatus.status, + ) + ? queueStatus.status === "testing" + ? "Testing…" + : `Queue #${queueStatus.position}` + : "Add to Queue"} + + + + {queueStatus + ? queueStatus.status === "merged" + ? "Merged via queue" + : queueStatus.status === "testing" + ? `CI running in lane ${queueStatus.lane_number ?? "?"}` + : queueStatus.status === "failed" + ? `Failed: ${queueStatus.failure_reason ?? "unknown"}` + : `In merge queue at position ${queueStatus.position}` + : "Add this branch to the merge queue"} + + + + )} {/* Merge button moved here */} {workspace && workspace.branch_name !== defaultBranch && ( diff --git a/src/components/WorkspaceSidebar.tsx b/src/components/WorkspaceSidebar.tsx index 6d8a909d..72fde8eb 100644 --- a/src/components/WorkspaceSidebar.tsx +++ b/src/components/WorkspaceSidebar.tsx @@ -8,7 +8,10 @@ import { getWorkspaces, listWorkspaceStatuses, } from "../lib/api"; -import type { WorkspaceSidebarStatus, QueueEntryStatus } from "../lib/api-types"; +import type { + WorkspaceSidebarStatus, + QueueEntryStatus, +} from "../lib/api-types"; import { useGitRemoteInfo } from "../hooks/useMergeQueueStatus"; import { supabase } from "../lib/supabase"; import { @@ -123,7 +126,10 @@ export const WorkspaceSidebar: React.FC = memo( p_repo_full_name: remoteInfo!.full_name, }); const map = new Map(); - for (const row of (data ?? []) as { branch_name: string; status: string }[]) { + for (const row of (data ?? []) as { + branch_name: string; + status: string; + }[]) { map.set(row.branch_name, row.status as QueueEntryStatus); } return map; @@ -380,7 +386,9 @@ export const WorkspaceSidebar: React.FC = memo( onDeleteWorkspace={onDeleteWorkspace} onRenameWorkspace={setRenameTarget} onDoubleClick={handleDoubleClick} - queueStatus={branchQueueStatuses?.get(node.status.current.branch_name)} + queueStatus={branchQueueStatuses?.get( + node.status.current.branch_name, + )} /> ))} {droppableProvided.placeholder} diff --git a/src/components/WorkspaceSidebarItem.tsx b/src/components/WorkspaceSidebarItem.tsx index 95de16ba..775cfc95 100644 --- a/src/components/WorkspaceSidebarItem.tsx +++ b/src/components/WorkspaceSidebarItem.tsx @@ -127,14 +127,26 @@ interface WorkspaceSidebarItemProps { queueStatus?: QueueEntryStatus; } -function queueStatusDot(status: QueueEntryStatus): { color: string; label: string } { +function queueStatusDot(status: QueueEntryStatus): { + color: string; + label: string; +} { switch (status) { - case "queued": return { color: "bg-yellow-400", label: "In merge queue" }; - case "testing": return { color: "bg-blue-400 animate-pulse", label: "CI running in merge queue" }; - case "passed": return { color: "bg-green-400", label: "Passed CI, awaiting merge" }; - case "merged": return { color: "bg-green-600", label: "Merged via queue" }; - case "failed": return { color: "bg-red-500", label: "Failed in merge queue" }; - default: return { color: "bg-muted-foreground", label: status }; + case "queued": + return { color: "bg-yellow-400", label: "In merge queue" }; + case "testing": + return { + color: "bg-blue-400 animate-pulse", + label: "CI running in merge queue", + }; + case "passed": + return { color: "bg-green-400", label: "Passed CI, awaiting merge" }; + case "merged": + return { color: "bg-green-600", label: "Merged via queue" }; + case "failed": + return { color: "bg-red-500", label: "Failed in merge queue" }; + default: + return { color: "bg-muted-foreground", label: status }; } } diff --git a/src/hooks/useMergeQueueStatus.ts b/src/hooks/useMergeQueueStatus.ts index 84cf0ff6..fb63eca0 100644 --- a/src/hooks/useMergeQueueStatus.ts +++ b/src/hooks/useMergeQueueStatus.ts @@ -44,7 +44,8 @@ export function useEnqueueWorkspace( const mutate = useCallback( async (action: "enqueue" | "dequeue") => { - if (!remoteInfo || !branchName) throw new Error("Repository or branch not detected"); + if (!remoteInfo || !branchName) + throw new Error("Repository or branch not detected"); const { error } = await supabase.functions.invoke("enqueue-workspace", { body: { repo_full_name: remoteInfo.full_name, diff --git a/src/lib/api.ts b/src/lib/api.ts index 898c4d2d..455db26e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -102,8 +102,9 @@ export const getWindowRepoPath = (): Promise => export const detectEditorApps = (): Promise => invoke("detect_editor_apps"); -export const getGitRemoteUrl = (repoPath: string): Promise => - invoke("get_git_remote_url", { repoPath }); +export const getGitRemoteUrl = ( + repoPath: string, +): Promise => invoke("get_git_remote_url", { repoPath }); // JJ Workspace API // JJ Diff API diff --git a/test/ast-grep/__tests__/__snapshots__/no-e2e-css-locator-snapshot.yml b/test/ast-grep/__tests__/__snapshots__/no-e2e-css-locator-snapshot.yml new file mode 100644 index 00000000..43c4b2d0 --- /dev/null +++ b/test/ast-grep/__tests__/__snapshots__/no-e2e-css-locator-snapshot.yml @@ -0,0 +1,30 @@ +id: no-e2e-css-locator +snapshots: + ? | + page.locator('#main-nav a'); + : labels: + - source: page.locator('#main-nav a') + style: primary + start: 0 + end: 27 + ? | + page.locator('.menu-item'); + : labels: + - source: page.locator('.menu-item') + style: primary + start: 0 + end: 26 + ? | + page.locator('button.primary'); + : labels: + - source: page.locator('button.primary') + style: primary + start: 0 + end: 30 + ? | + page.locator('nav.navbar'); + : labels: + - source: page.locator('nav.navbar') + style: primary + start: 0 + end: 26 diff --git a/test/ast-grep/__tests__/__snapshots__/no-e2e-direct-url-goto-snapshot.yml b/test/ast-grep/__tests__/__snapshots__/no-e2e-direct-url-goto-snapshot.yml new file mode 100644 index 00000000..ac221a45 --- /dev/null +++ b/test/ast-grep/__tests__/__snapshots__/no-e2e-direct-url-goto-snapshot.yml @@ -0,0 +1,23 @@ +id: no-e2e-direct-url-goto +snapshots: + ? | + await page.goto('/docs/guides'); + : labels: + - source: page.goto('/docs/guides') + style: primary + start: 6 + end: 31 + ? | + await page.goto('/docs/intro'); + : labels: + - source: page.goto('/docs/intro') + style: primary + start: 6 + end: 30 + ? | + await page.goto('/learn/installation'); + : labels: + - source: page.goto('/learn/installation') + style: primary + start: 6 + end: 38 diff --git a/test/ast-grep/__tests__/__snapshots__/no-e2e-js-evaluate-snapshot.yml b/test/ast-grep/__tests__/__snapshots__/no-e2e-js-evaluate-snapshot.yml new file mode 100644 index 00000000..518dc534 --- /dev/null +++ b/test/ast-grep/__tests__/__snapshots__/no-e2e-js-evaluate-snapshot.yml @@ -0,0 +1,30 @@ +id: no-e2e-js-evaluate +snapshots: + ? | + await locator.evaluateAll(els => els.length); + : labels: + - source: locator.evaluateAll(els => els.length) + style: primary + start: 6 + end: 44 + ? | + await page.addInitScript(() => { window.foo = 'bar'; }); + : labels: + - source: page.addInitScript(() => { window.foo = 'bar'; }) + style: primary + start: 6 + end: 55 + ? | + await page.evaluate(() => document.querySelector('.nav')); + : labels: + - source: page.evaluate(() => document.querySelector('.nav')) + style: primary + start: 6 + end: 57 + ? | + await page.evaluateHandle(() => window.location); + : labels: + - source: page.evaluateHandle(() => window.location) + style: primary + start: 6 + end: 48 diff --git a/test/ast-grep/__tests__/__snapshots__/no-e2e-query-selector-snapshot.yml b/test/ast-grep/__tests__/__snapshots__/no-e2e-query-selector-snapshot.yml new file mode 100644 index 00000000..c7c3a6a6 --- /dev/null +++ b/test/ast-grep/__tests__/__snapshots__/no-e2e-query-selector-snapshot.yml @@ -0,0 +1,30 @@ +id: no-e2e-query-selector +snapshots: + ? | + await page.$$('.menu-item'); + : labels: + - source: page.$$('.menu-item') + style: primary + start: 6 + end: 27 + ? | + await page.$$eval('.items', els => els.length); + : labels: + - source: page.$$eval('.items', els => els.length) + style: primary + start: 6 + end: 46 + ? | + await page.$('nav.navbar'); + : labels: + - source: page.$('nav.navbar') + style: primary + start: 6 + end: 26 + ? | + await page.$eval('.button', el => el.textContent); + : labels: + - source: page.$eval('.button', el => el.textContent) + style: primary + start: 6 + end: 49 diff --git a/test/ast-grep/__tests__/__snapshots__/no-e2e-url-assertion-snapshot.yml b/test/ast-grep/__tests__/__snapshots__/no-e2e-url-assertion-snapshot.yml new file mode 100644 index 00000000..9ad3db0f --- /dev/null +++ b/test/ast-grep/__tests__/__snapshots__/no-e2e-url-assertion-snapshot.yml @@ -0,0 +1,16 @@ +id: no-e2e-url-assertion +snapshots: + ? | + await expect(page).toHaveURL('/docs/guides'); + : labels: + - source: expect(page).toHaveURL('/docs/guides') + style: primary + start: 6 + end: 44 + ? | + await expect(page).toHaveURL(/\/learn\/installation/); + : labels: + - source: expect(page).toHaveURL(/\/learn\/installation/) + style: primary + start: 6 + end: 53 diff --git a/test/ast-grep/__tests__/__snapshots__/no-e2e-xpath-snapshot.yml b/test/ast-grep/__tests__/__snapshots__/no-e2e-xpath-snapshot.yml new file mode 100644 index 00000000..0fcecc61 --- /dev/null +++ b/test/ast-grep/__tests__/__snapshots__/no-e2e-xpath-snapshot.yml @@ -0,0 +1,23 @@ +id: no-e2e-xpath +snapshots: + ? | + page.locator('//*[@aria-label="Main"]'); + : labels: + - source: page.locator('//*[@aria-label="Main"]') + style: primary + start: 0 + end: 39 + ? | + page.locator('//nav'); + : labels: + - source: page.locator('//nav') + style: primary + start: 0 + end: 21 + ? | + page.locator('xpath=//nav/a'); + : labels: + - source: page.locator('xpath=//nav/a') + style: primary + start: 0 + end: 29 From 82faac1c8017eaf60a7599df29c759aa9f4250ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:33:56 +0000 Subject: [PATCH 04/42] feat: use gh CLI for PR lookup before falling back to GH app proxy Adds a `get_pr_info_via_gh` Tauri command that runs `gh pr view` to retrieve PR metadata (number, title, state, URL, base/head refs, merge state status) without requiring the Supabase edge function. The `usePrInfoViaGh` hook caches the result and feeds it to `useEnqueueWorkspace` as a pre-flight check: if gh reports the branch has no open PR, enqueue is rejected early with a clear error message instead of letting the edge function fail silently. --- src-tauri/src/commands/github.rs | 71 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/hooks/useMergeQueueStatus.ts | 42 ++++++++++++++++--- src/lib/api-types.ts | 11 +++++ src/lib/api.ts | 7 ++++ 5 files changed, 127 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs index 84968d6c..14b2c463 100644 --- a/src-tauri/src/commands/github.rs +++ b/src-tauri/src/commands/github.rs @@ -1,5 +1,7 @@ use std::path::Path; +use crate::binary_paths::{detect_binary, get_binary_path}; + #[derive(serde::Serialize)] pub struct GitRemoteInfo { pub owner: String, @@ -7,6 +9,75 @@ pub struct GitRemoteInfo { pub full_name: String, } +#[derive(serde::Serialize, serde::Deserialize)] +pub struct PrInfo { + pub number: u64, + pub title: String, + pub state: String, + pub url: String, + pub head_ref_name: String, + pub base_ref_name: String, + pub merge_state_status: Option, +} + +/// Run `gh pr view` for the given branch in the given repo directory. +/// Returns None if gh is not installed, not authenticated, or no PR exists. +#[tauri::command] +pub fn get_pr_info_via_gh( + repo_path: String, + branch_name: String, +) -> Result, String> { + let gh = get_binary_path("gh") + .or_else(|| detect_binary("gh")) + .ok_or_else(|| "gh CLI not found".to_string())?; + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct GhPrView { + number: u64, + title: String, + state: String, + url: String, + head_ref_name: String, + base_ref_name: String, + merge_state_status: Option, + } + + let output = std::process::Command::new(&gh) + .args([ + "pr", + "view", + &branch_name, + "--json", + "number,title,state,url,headRefName,baseRefName,mergeStateStatus", + ]) + .current_dir(&repo_path) + .env( + "PATH", + crate::binary_paths::get_extended_path(), + ) + .output() + .map_err(|e| e.to_string())?; + + if !output.status.success() { + // gh exits non-zero when no PR exists or not authenticated — treat as None + return Ok(None); + } + + let raw: GhPrView = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Failed to parse gh output: {e}"))?; + + Ok(Some(PrInfo { + number: raw.number, + title: raw.title, + state: raw.state, + url: raw.url, + head_ref_name: raw.head_ref_name, + base_ref_name: raw.base_ref_name, + merge_state_status: raw.merge_state_status, + })) +} + /// Parse owner/repo from a GitHub remote URL in either HTTPS or SSH form. fn parse_github_remote(url: &str) -> Option { let url = url.trim(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f36d4a72..39724c56 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -503,6 +503,7 @@ pub fn run() { commands::rebase_home_repo_branch, commands::dry_run_home_repo_rebase, commands::get_git_remote_url, + commands::get_pr_info_via_gh, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/hooks/useMergeQueueStatus.ts b/src/hooks/useMergeQueueStatus.ts index fb63eca0..35806192 100644 --- a/src/hooks/useMergeQueueStatus.ts +++ b/src/hooks/useMergeQueueStatus.ts @@ -1,15 +1,36 @@ import { useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { getGitRemoteUrl } from "../lib/api"; +import { getGitRemoteUrl, getPrInfoViaGh } from "../lib/api"; import { supabase } from "../lib/supabase"; -import type { WorkspaceQueueStatus } from "../lib/api-types"; +import type { PrInfo, WorkspaceQueueStatus } from "../lib/api-types"; export function useGitRemoteInfo(repoPath: string | undefined) { return useQuery({ queryKey: ["git-remote-info", repoPath], queryFn: () => getGitRemoteUrl(repoPath!), enabled: !!repoPath, - staleTime: 5 * 60 * 1000, // remote URL rarely changes + staleTime: 5 * 60 * 1000, + }); +} + +/** Try `gh pr view` first; returns null if gh is not available or no PR exists. */ +export function usePrInfoViaGh( + repoPath: string | undefined, + branchName: string | undefined, +) { + return useQuery({ + queryKey: ["pr-info-gh", repoPath, branchName], + queryFn: async () => { + try { + return await getPrInfoViaGh(repoPath!, branchName!); + } catch { + // gh not installed or errored — surface as null, not an error + return null; + } + }, + enabled: !!repoPath && !!branchName, + staleTime: 30_000, + refetchInterval: 60_000, }); } @@ -41,11 +62,21 @@ export function useEnqueueWorkspace( ) { const queryClient = useQueryClient(); const { data: remoteInfo } = useGitRemoteInfo(repoPath); + const { data: prInfoGh } = usePrInfoViaGh(repoPath, branchName); const mutate = useCallback( async (action: "enqueue" | "dequeue") => { if (!remoteInfo || !branchName) throw new Error("Repository or branch not detected"); + + if (prInfoGh !== undefined && prInfoGh !== null) { + if (prInfoGh.state !== "OPEN" && action === "enqueue") { + throw new Error( + `No open PR found for branch '${branchName}' (gh reports: ${prInfoGh.state})`, + ); + } + } + const { error } = await supabase.functions.invoke("enqueue-workspace", { body: { repo_full_name: remoteInfo.full_name, @@ -54,15 +85,16 @@ export function useEnqueueWorkspace( }, }); if (error) throw error; + await queryClient.invalidateQueries({ queryKey: ["merge-queue-status", remoteInfo.full_name, branchName], }); }, - [remoteInfo, branchName, queryClient], + [remoteInfo, branchName, prInfoGh, queryClient], ); const enqueue = useMutation({ mutationFn: () => mutate("enqueue") }); const dequeue = useMutation({ mutationFn: () => mutate("dequeue") }); - return { enqueue, dequeue, remoteInfo }; + return { enqueue, dequeue, remoteInfo, prInfoGh }; } diff --git a/src/lib/api-types.ts b/src/lib/api-types.ts index 734e66b0..8c9366b7 100644 --- a/src/lib/api-types.ts +++ b/src/lib/api-types.ts @@ -327,6 +327,17 @@ export interface GitRemoteInfo { full_name: string; } +export interface PrInfo { + number: number; + title: string; + /** "OPEN" | "CLOSED" | "MERGED" */ + state: string; + url: string; + head_ref_name: string; + base_ref_name: string; + merge_state_status: string | null; +} + export type QueueEntryStatus = | "queued" | "testing" diff --git a/src/lib/api.ts b/src/lib/api.ts index 455db26e..57c072f3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3,6 +3,7 @@ import type { DirectoryEntry, EditorAppsResponse, GitRemoteInfo, + PrInfo, HomeRebaseDryRunResult, JjBranch, JjCommitsAhead, @@ -106,6 +107,12 @@ export const getGitRemoteUrl = ( repoPath: string, ): Promise => invoke("get_git_remote_url", { repoPath }); +export const getPrInfoViaGh = ( + repoPath: string, + branchName: string, +): Promise => + invoke("get_pr_info_via_gh", { repoPath, branchName }); + // JJ Workspace API // JJ Diff API export const getWorkspaceChangedFiles = ( From a26719a89273650d581b92aa632032a42713a376 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:34:25 +0000 Subject: [PATCH 05/42] chore: restore web files from main (jsonLdPlugin and DocItem layout) --- web/plugins/jsonLdPlugin.js | 46 +++++++++++++++++++ web/src/theme/DocItem/Layout/index.tsx | 62 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 web/plugins/jsonLdPlugin.js create mode 100644 web/src/theme/DocItem/Layout/index.tsx diff --git a/web/plugins/jsonLdPlugin.js b/web/plugins/jsonLdPlugin.js new file mode 100644 index 00000000..5b4f3b77 --- /dev/null +++ b/web/plugins/jsonLdPlugin.js @@ -0,0 +1,46 @@ +'use strict'; + +const SITE_URL = 'https://treq.dev'; + +module.exports = function jsonLdPlugin() { + return { + name: 'json-ld-plugin', + injectHtmlTags() { + return { + headTags: [ + { + tagName: 'script', + attributes: {type: 'application/ld+json'}, + innerHTML: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'Treq', + url: SITE_URL, + description: 'The Open Source Graphite Alternative', + potentialAction: { + '@type': 'SearchAction', + target: { + '@type': 'EntryPoint', + urlTemplate: `${SITE_URL}/search?q={search_term_string}`, + }, + 'query-input': 'required name=search_term_string', + }, + }), + }, + { + tagName: 'script', + attributes: {type: 'application/ld+json'}, + innerHTML: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Organization', + name: 'Treq', + url: SITE_URL, + logo: `${SITE_URL}/img/favicon.svg`, + sameAs: ['https://github.com/Ziinc/treq'], + }), + }, + ], + }; + }, + }; +}; diff --git a/web/src/theme/DocItem/Layout/index.tsx b/web/src/theme/DocItem/Layout/index.tsx new file mode 100644 index 00000000..cab7dc68 --- /dev/null +++ b/web/src/theme/DocItem/Layout/index.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import DocItemLayout from '@theme-original/DocItem/Layout'; +import type {Props} from '@theme/DocItem/Layout'; +import Head from '@docusaurus/Head'; +import {useDoc} from '@docusaurus/plugin-content-docs/client'; + +const SITE_URL = 'https://treq.dev'; +const ORG = {'@type': 'Organization', name: 'Treq', url: SITE_URL}; + +function buildSchema(metadata: ReturnType['metadata']) { + const {title, description, permalink, lastUpdatedAt} = metadata; + const url = `${SITE_URL}${permalink}`; + const base = { + '@context': 'https://schema.org', + ...(description ? {description} : {}), + url, + ...(lastUpdatedAt + ? {dateModified: new Date(lastUpdatedAt * 1000).toISOString()} + : {}), + }; + + if (permalink.includes('/how-to/')) { + return {'@type': 'HowTo', name: title, ...base, publisher: ORG}; + } + + if (permalink.startsWith('/learn/tutorials/')) { + return { + '@type': 'LearningResource', + name: title, + ...base, + learningResourceType: 'Tutorial', + educationalLevel: 'Beginner', + provider: ORG, + }; + } + + if (permalink.startsWith('/learn/')) { + return { + '@type': 'LearningResource', + name: title, + ...base, + educationalLevel: 'Beginner', + provider: ORG, + }; + } + + return {'@type': 'TechArticle', headline: title, ...base, publisher: ORG}; +} + +export default function DocItemLayoutWrapper(props: Props): React.ReactElement { + const {metadata} = useDoc(); + return ( + <> + + + + + + ); +} From 20795f0d95102948af132868f6c9ca3574fd4996 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 07:32:43 +0000 Subject: [PATCH 06/42] test: add Rust and JS integration tests for gh CLI PR lookup Rust (src-tauri/src/github.rs): - Extract github logic into a public treq_lib module so it's testable without GTK/Tauri deps and reachable from the NAPI dispatch layer - 14 inline unit tests covering parse_github_remote (SSH, HTTPS, invalid), get_git_remote_url_impl (ssh, https, gitlab, missing dir, non-origin), and get_pr_info_via_gh_impl (success JSON, non-zero exit, bad binary) using a fake gh shell script to avoid requiring a real gh auth session JS (test/integration/useMergeQueueStatus.test.ts): - useGitRemoteInfo: real NAPI against temp git repos with real .git/config - usePrInfoViaGh: verifies the hook never surfaces an error state even when gh is not authenticated - useEnqueueWorkspace: spies on getPrInfoViaGh to inject controlled PrInfo so the MERGED-state pre-flight guard and dequeue bypass are exercisable without a real gh session; supabase edge function is always mocked NAPI dispatch (crates/treq-napi/src/dispatch.rs): - Add get_git_remote_url and get_pr_info_via_gh so the command-consistency integration test continues to pass CI (.github/workflows/ci.yml): - Install gh CLI on both ubuntu-22.04 (via official apt source) and macOS (via brew) so the fake-gh Rust tests and real-gh integration paths work - Add test/** to paths filter so CI runs when test files change --- .github/workflows/ci.yml | 19 +- crates/treq-napi/src/dispatch.rs | 22 ++ src-tauri/src/commands/github.rs | 129 +------ src-tauri/src/github.rs | 344 +++++++++++++++++++ src-tauri/src/lib.rs | 1 + test/integration/useMergeQueueStatus.test.ts | 238 +++++++++++++ 6 files changed, 626 insertions(+), 127 deletions(-) create mode 100644 src-tauri/src/github.rs create mode 100644 test/integration/useMergeQueueStatus.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba64d783..e0db4595 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,14 @@ on: paths: - "src/**" - "src-tauri/**" + - "test/**" - ".github/workflows/ci.yml" pull_request: branches: [main] paths: - "src/**" - "src-tauri/**" + - "test/**" - ".github/workflows/ci.yml" jobs: @@ -62,11 +64,11 @@ jobs: sudo apt-get install -y git sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: Install git and jj CLI (macOS) + - name: Install git, jj, and gh CLI (macOS) if: matrix.platform == 'macos-latest' run: | brew update - brew install git jj + brew install git jj gh echo "$(brew --prefix)/bin" >> $GITHUB_PATH - name: Install jj CLI (ubuntu) @@ -75,10 +77,23 @@ jobs: cargo binstall --strategies crate-meta-data jj-cli echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install gh CLI (ubuntu) + if: matrix.platform == 'ubuntu-22.04' + run: | + (type -p wget >/dev/null || (sudo apt-get update && sudo apt-get install -y wget)) \ + && sudo mkdir -p -m 755 /etc/apt/keyrings \ + && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ + && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && sudo apt-get update \ + && sudo apt-get install -y gh + - name: Verify pinned tool versions run: | git --version jj --version + gh --version - name: Install dependencies run: npm ci diff --git a/crates/treq-napi/src/dispatch.rs b/crates/treq-napi/src/dispatch.rs index 8bda4537..3ac6ae5d 100644 --- a/crates/treq-napi/src/dispatch.rs +++ b/crates/treq-napi/src/dispatch.rs @@ -27,6 +27,28 @@ pub fn dispatch(command: &str, args: Value) -> Result { Ok(Value::String(dir)) } + // ── GitHub helpers ──────────────────────────────────────────────── + "get_git_remote_url" => { + let repo_path = get_str(&args, "repoPath")?; + let info = treq_lib::github::get_git_remote_url_impl(&repo_path)?; + serde_json::to_value(info).map_err(|e| e.to_string()) + } + + "get_pr_info_via_gh" => { + let repo_path = get_str(&args, "repoPath")?; + let branch_name = get_str(&args, "branchName")?; + let gh = treq_lib::binary_paths::detect_binary("gh") + .ok_or_else(|| "gh CLI not found".to_string())?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + let info = treq_lib::github::get_pr_info_via_gh_impl( + &gh, + &repo_path, + &branch_name, + &extended_path, + )?; + serde_json::to_value(info).map_err(|e| e.to_string()) + } + // ── Settings ────────────────────────────────────────────────────── "get_setting" => { let key = get_str(&args, "key")?; diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs index 14b2c463..27a99401 100644 --- a/src-tauri/src/commands/github.rs +++ b/src-tauri/src/commands/github.rs @@ -1,24 +1,5 @@ -use std::path::Path; - -use crate::binary_paths::{detect_binary, get_binary_path}; - -#[derive(serde::Serialize)] -pub struct GitRemoteInfo { - pub owner: String, - pub repo: String, - pub full_name: String, -} - -#[derive(serde::Serialize, serde::Deserialize)] -pub struct PrInfo { - pub number: u64, - pub title: String, - pub state: String, - pub url: String, - pub head_ref_name: String, - pub base_ref_name: String, - pub merge_state_status: Option, -} +use crate::binary_paths::{detect_binary, get_binary_path, get_extended_path}; +pub use crate::github::{GitRemoteInfo, PrInfo}; /// Run `gh pr view` for the given branch in the given repo directory. /// Returns None if gh is not installed, not authenticated, or no PR exists. @@ -31,114 +12,12 @@ pub fn get_pr_info_via_gh( .or_else(|| detect_binary("gh")) .ok_or_else(|| "gh CLI not found".to_string())?; - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct GhPrView { - number: u64, - title: String, - state: String, - url: String, - head_ref_name: String, - base_ref_name: String, - merge_state_status: Option, - } - - let output = std::process::Command::new(&gh) - .args([ - "pr", - "view", - &branch_name, - "--json", - "number,title,state,url,headRefName,baseRefName,mergeStateStatus", - ]) - .current_dir(&repo_path) - .env( - "PATH", - crate::binary_paths::get_extended_path(), - ) - .output() - .map_err(|e| e.to_string())?; - - if !output.status.success() { - // gh exits non-zero when no PR exists or not authenticated — treat as None - return Ok(None); - } - - let raw: GhPrView = serde_json::from_slice(&output.stdout) - .map_err(|e| format!("Failed to parse gh output: {e}"))?; - - Ok(Some(PrInfo { - number: raw.number, - title: raw.title, - state: raw.state, - url: raw.url, - head_ref_name: raw.head_ref_name, - base_ref_name: raw.base_ref_name, - merge_state_status: raw.merge_state_status, - })) -} - -/// Parse owner/repo from a GitHub remote URL in either HTTPS or SSH form. -fn parse_github_remote(url: &str) -> Option { - let url = url.trim(); - - // SSH: git@github.com:owner/repo.git - if let Some(rest) = url.strip_prefix("git@github.com:") { - let path = rest.trim_end_matches(".git"); - let (owner, repo) = path.split_once('/')?; - return Some(GitRemoteInfo { - owner: owner.to_string(), - repo: repo.to_string(), - full_name: format!("{}/{}", owner, repo), - }); - } - - // HTTPS: https://github.com/owner/repo.git - for prefix in &["https://github.com/", "http://github.com/"] { - if let Some(rest) = url.strip_prefix(prefix) { - let path = rest.trim_end_matches(".git"); - let (owner, repo) = path.split_once('/')?; - return Some(GitRemoteInfo { - owner: owner.to_string(), - repo: repo.to_string(), - full_name: format!("{}/{}", owner, repo), - }); - } - } - - None + crate::github::get_pr_info_via_gh_impl(&gh, &repo_path, &branch_name, &get_extended_path()) } /// Read the GitHub remote URL from .git/config and parse owner/repo. /// Returns None if no GitHub remote is found. #[tauri::command] pub fn get_git_remote_url(repo_path: String) -> Result, String> { - let git_config_path = Path::new(&repo_path).join(".git").join("config"); - if !git_config_path.exists() { - return Ok(None); - } - - let contents = std::fs::read_to_string(&git_config_path).map_err(|e| e.to_string())?; - - // Parse INI-style .git/config: find [remote "origin"] section and its url - let mut in_origin_remote = false; - for line in contents.lines() { - let trimmed = line.trim(); - if trimmed.starts_with('[') { - in_origin_remote = trimmed == r#"[remote "origin"]"#; - continue; - } - if in_origin_remote { - if let Some(rest) = trimmed.strip_prefix("url") { - let rest = rest.trim_start(); - if let Some(url) = rest.strip_prefix('=') { - if let Some(info) = parse_github_remote(url.trim()) { - return Ok(Some(info)); - } - } - } - } - } - - Ok(None) + crate::github::get_git_remote_url_impl(&repo_path) } diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs new file mode 100644 index 00000000..441e4ead --- /dev/null +++ b/src-tauri/src/github.rs @@ -0,0 +1,344 @@ +use std::path::Path; + +#[derive(serde::Serialize, Clone)] +pub struct GitRemoteInfo { + pub owner: String, + pub repo: String, + pub full_name: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct PrInfo { + pub number: u64, + pub title: String, + pub state: String, + pub url: String, + pub head_ref_name: String, + pub base_ref_name: String, + pub merge_state_status: Option, +} + +/// Parse owner/repo from a GitHub remote URL in SSH or HTTPS form. +/// Returns None if the URL is not a recognized GitHub remote. +pub fn parse_github_remote(url: &str) -> Option { + let url = url.trim(); + + // SSH: git@github.com:owner/repo.git + if let Some(rest) = url.strip_prefix("git@github.com:") { + let path = rest.trim_end_matches(".git"); + let (owner, repo) = path.split_once('/')?; + return Some(GitRemoteInfo { + owner: owner.to_string(), + repo: repo.to_string(), + full_name: format!("{owner}/{repo}"), + }); + } + + // HTTPS: https://github.com/owner/repo[.git] + for prefix in &["https://github.com/", "http://github.com/"] { + if let Some(rest) = url.strip_prefix(prefix) { + let path = rest.trim_end_matches(".git"); + let (owner, repo) = path.split_once('/')?; + return Some(GitRemoteInfo { + owner: owner.to_string(), + repo: repo.to_string(), + full_name: format!("{owner}/{repo}"), + }); + } + } + + None +} + +/// Read the GitHub remote URL from .git/config and parse owner/repo. +/// Returns None if no GitHub remote is found or the directory is not a git repo. +pub fn get_git_remote_url_impl(repo_path: &str) -> Result, String> { + let git_config_path = Path::new(repo_path).join(".git").join("config"); + if !git_config_path.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(&git_config_path).map_err(|e| e.to_string())?; + + // Parse INI-style .git/config: find [remote "origin"] section and its url + let mut in_origin_remote = false; + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_origin_remote = trimmed == r#"[remote "origin"]"#; + continue; + } + if in_origin_remote { + if let Some(rest) = trimmed.strip_prefix("url") { + let rest = rest.trim_start(); + if let Some(url) = rest.strip_prefix('=') { + if let Some(info) = parse_github_remote(url.trim()) { + return Ok(Some(info)); + } + } + } + } + } + + Ok(None) +} + +/// Run `gh pr view` for the given branch in the given repo directory. +/// Returns None if gh is not installed, not authenticated, or no PR exists. +/// The `gh_path` argument is the resolved path to the gh binary. +pub fn get_pr_info_via_gh_impl( + gh_path: &str, + repo_path: &str, + branch_name: &str, + extended_path: &str, +) -> Result, String> { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct GhPrView { + number: u64, + title: String, + state: String, + url: String, + head_ref_name: String, + base_ref_name: String, + merge_state_status: Option, + } + + let output = std::process::Command::new(gh_path) + .args([ + "pr", + "view", + branch_name, + "--json", + "number,title,state,url,headRefName,baseRefName,mergeStateStatus", + ]) + .current_dir(repo_path) + .env("PATH", extended_path) + .output() + .map_err(|e| e.to_string())?; + + if !output.status.success() { + // gh exits non-zero when no PR exists or not authenticated — treat as None + return Ok(None); + } + + let raw: GhPrView = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Failed to parse gh output: {e}"))?; + + Ok(Some(PrInfo { + number: raw.number, + title: raw.title, + state: raw.state, + url: raw.url, + head_ref_name: raw.head_ref_name, + base_ref_name: raw.base_ref_name, + merge_state_status: raw.merge_state_status, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + use tempfile::TempDir; + + // ── parse_github_remote ────────────────────────────────────────────────── + + #[test] + fn test_parse_ssh_url() { + let info = parse_github_remote("git@github.com:owner/repo.git").unwrap(); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo, "repo"); + assert_eq!(info.full_name, "owner/repo"); + } + + #[test] + fn test_parse_https_url() { + let info = parse_github_remote("https://github.com/owner/repo.git").unwrap(); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo, "repo"); + assert_eq!(info.full_name, "owner/repo"); + } + + #[test] + fn test_parse_https_url_no_dot_git() { + let info = parse_github_remote("https://github.com/owner/repo").unwrap(); + assert_eq!(info.full_name, "owner/repo"); + } + + #[test] + fn test_parse_non_github_url_returns_none() { + assert!(parse_github_remote("https://gitlab.com/owner/repo.git").is_none()); + } + + #[test] + fn test_parse_invalid_url_returns_none() { + assert!(parse_github_remote("not-a-url").is_none()); + } + + #[test] + fn test_parse_trims_whitespace() { + let info = parse_github_remote(" git@github.com:owner/repo.git ").unwrap(); + assert_eq!(info.full_name, "owner/repo"); + } + + // ── get_git_remote_url_impl ────────────────────────────────────────────── + + fn write_git_config(dir: &TempDir, content: &str) { + let git_dir = dir.path().join(".git"); + fs::create_dir_all(&git_dir).unwrap(); + fs::write(git_dir.join("config"), content).unwrap(); + } + + #[test] + fn test_get_git_remote_url_parses_ssh_origin() { + let dir = TempDir::new().unwrap(); + write_git_config( + &dir, + r#"[core] + repositoryformatversion = 0 +[remote "origin"] + url = git@github.com:ziinc/treq.git + fetch = +refs/heads/*:refs/remotes/origin/* +"#, + ); + let info = get_git_remote_url_impl(dir.path().to_str().unwrap()) + .unwrap() + .unwrap(); + assert_eq!(info.owner, "ziinc"); + assert_eq!(info.repo, "treq"); + assert_eq!(info.full_name, "ziinc/treq"); + } + + #[test] + fn test_get_git_remote_url_parses_https_origin() { + let dir = TempDir::new().unwrap(); + write_git_config( + &dir, + r#"[remote "origin"] + url = https://github.com/ziinc/treq.git +"#, + ); + let info = get_git_remote_url_impl(dir.path().to_str().unwrap()) + .unwrap() + .unwrap(); + assert_eq!(info.full_name, "ziinc/treq"); + } + + #[test] + fn test_get_git_remote_url_non_github_remote_returns_none() { + let dir = TempDir::new().unwrap(); + write_git_config( + &dir, + r#"[remote "origin"] + url = https://gitlab.com/owner/repo.git +"#, + ); + let result = get_git_remote_url_impl(dir.path().to_str().unwrap()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_get_git_remote_url_missing_git_dir_returns_none() { + let dir = TempDir::new().unwrap(); + let result = get_git_remote_url_impl(dir.path().to_str().unwrap()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_get_git_remote_url_ignores_non_origin_remotes() { + let dir = TempDir::new().unwrap(); + write_git_config( + &dir, + r#"[remote "upstream"] + url = git@github.com:owner/repo.git +"#, + ); + let result = get_git_remote_url_impl(dir.path().to_str().unwrap()).unwrap(); + assert!(result.is_none()); + } + + // ── get_pr_info_via_gh_impl ────────────────────────────────────────────── + + #[cfg(unix)] + fn write_fake_gh(dir: &TempDir, script_body: &str) -> String { + let path = dir.path().join("gh"); + let mut f = fs::File::create(&path).unwrap(); + writeln!(f, "#!/bin/sh").unwrap(); + write!(f, "{script_body}").unwrap(); + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path.to_str().unwrap().to_string() + } + + #[test] + #[cfg(unix)] + fn test_get_pr_info_via_gh_returns_parsed_pr() { + let bin_dir = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + let gh_path = write_fake_gh( + &bin_dir, + r#"echo '{"number":42,"title":"My PR","state":"OPEN","url":"https://github.com/o/r/pull/42","headRefName":"feat","baseRefName":"main","mergeStateStatus":"CLEAN"}'"#, + ); + + let info = get_pr_info_via_gh_impl( + &gh_path, + repo_dir.path().to_str().unwrap(), + "feat", + "/usr/bin:/bin", + ) + .unwrap() + .unwrap(); + + assert_eq!(info.number, 42); + assert_eq!(info.title, "My PR"); + assert_eq!(info.state, "OPEN"); + assert_eq!(info.head_ref_name, "feat"); + assert_eq!(info.base_ref_name, "main"); + assert_eq!(info.merge_state_status.as_deref(), Some("CLEAN")); + } + + #[test] + #[cfg(unix)] + fn test_get_pr_info_via_gh_nonzero_exit_returns_none() { + let bin_dir = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + let gh_path = write_fake_gh(&bin_dir, "exit 1"); + + let result = get_pr_info_via_gh_impl( + &gh_path, + repo_dir.path().to_str().unwrap(), + "feat", + "/usr/bin:/bin", + ) + .unwrap(); + + assert!(result.is_none()); + } + + #[test] + #[cfg(unix)] + fn test_get_pr_info_via_gh_no_pr_state_returns_none() { + let bin_dir = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + // gh exits 1 with a "no pull requests found" message on stderr + let gh_path = write_fake_gh(&bin_dir, "echo 'no pull requests found' >&2\nexit 1"); + + let result = get_pr_info_via_gh_impl( + &gh_path, + repo_dir.path().to_str().unwrap(), + "unknown-branch", + "/usr/bin:/bin", + ) + .unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn test_get_pr_info_via_gh_bad_binary_returns_err() { + let result = get_pr_info_via_gh_impl("/nonexistent/gh", "/tmp", "feat", "/usr/bin:/bin"); + assert!(result.is_err()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39724c56..31957293 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ pub mod conflict_markers; pub mod core; pub mod db; pub mod file_indexer; +pub mod github; pub mod jj; pub mod local_db; pub mod pty; diff --git a/test/integration/useMergeQueueStatus.test.ts b/test/integration/useMergeQueueStatus.test.ts new file mode 100644 index 00000000..1bbe9fd3 --- /dev/null +++ b/test/integration/useMergeQueueStatus.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import fs from "node:fs"; +import path from "node:path"; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as api from "../../src/lib/api"; +import { + useGitRemoteInfo, + usePrInfoViaGh, + useEnqueueWorkspace, +} from "../../src/hooks/useMergeQueueStatus"; +import type { PrInfo } from "../../src/lib/api-types"; +import { createTestRepo } from "../utils"; + +const mockEdgeFn = vi.fn(); +vi.mock("../../src/lib/supabase", () => ({ + supabase: { + rpc: vi.fn().mockResolvedValue({ data: [], error: null }), + functions: { invoke: mockEdgeFn }, + }, +})); + +function makeWrapper() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children); +} + +const OPEN_PR: PrInfo = { + number: 1, + title: "My PR", + state: "OPEN", + url: "https://github.com/ziinc/treq/pull/1", + head_ref_name: "feat", + base_ref_name: "main", + merge_state_status: "CLEAN", +}; + +function addGitHubRemote(repoPath: string, remoteUrl: string) { + const configPath = path.join(repoPath, ".git", "config"); + const existing = fs.existsSync(configPath) + ? fs.readFileSync(configPath, "utf-8") + : ""; + fs.writeFileSync( + configPath, + `${existing}[remote "origin"]\n\turl = ${remoteUrl}\n`, + ); +} + +describe("useGitRemoteInfo", () => { + it("returns null when repo has no .git directory", async () => { + const { repoPath } = createTestRepo(false); + fs.rmSync(path.join(repoPath, ".git"), { recursive: true, force: true }); + + const { result } = renderHook(() => useGitRemoteInfo(repoPath), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeNull(); + }); + + it("returns null when origin is not a GitHub URL", async () => { + const { repoPath } = createTestRepo(false); + addGitHubRemote(repoPath, "https://gitlab.com/owner/repo.git"); + + const { result } = renderHook(() => useGitRemoteInfo(repoPath), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeNull(); + }); + + it("parses SSH GitHub remote from .git/config", async () => { + const { repoPath } = createTestRepo(false); + addGitHubRemote(repoPath, "git@github.com:ziinc/treq.git"); + + const { result } = renderHook(() => useGitRemoteInfo(repoPath), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject({ + owner: "ziinc", + repo: "treq", + full_name: "ziinc/treq", + }); + }); + + it("parses HTTPS GitHub remote from .git/config", async () => { + const { repoPath } = createTestRepo(false); + addGitHubRemote(repoPath, "https://github.com/ziinc/treq.git"); + + const { result } = renderHook(() => useGitRemoteInfo(repoPath), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.full_name).toBe("ziinc/treq"); + }); + + it("is disabled when repoPath is undefined", () => { + const { result } = renderHook(() => useGitRemoteInfo(undefined), { + wrapper: makeWrapper(), + }); + expect(result.current.fetchStatus).toBe("idle"); + }); +}); + +describe("usePrInfoViaGh", () => { + it("settles to success state and never enters error state", async () => { + const { repoPath } = createTestRepo(false); + + const { result } = renderHook( + () => usePrInfoViaGh(repoPath, "non-existent-branch"), + { wrapper: makeWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true), { + timeout: 10_000, + }); + expect(result.current.isError).toBe(false); + }); + + it("is disabled when branchName is undefined", () => { + const { repoPath } = createTestRepo(false); + const { result } = renderHook(() => usePrInfoViaGh(repoPath, undefined), { + wrapper: makeWrapper(), + }); + expect(result.current.fetchStatus).toBe("idle"); + }); +}); + +describe("useEnqueueWorkspace", () => { + let ghSpy: ReturnType; + + beforeEach(() => { + mockEdgeFn.mockReset(); + ghSpy = vi.spyOn(api, "getPrInfoViaGh"); + }); + + afterEach(() => { + ghSpy.mockRestore(); + }); + + it("calls enqueue-workspace edge function with correct payload", async () => { + const { repoPath } = createTestRepo(false); + addGitHubRemote(repoPath, "git@github.com:ziinc/treq.git"); + ghSpy.mockResolvedValue(null); + mockEdgeFn.mockResolvedValue({ error: null }); + + const { result } = renderHook(() => useEnqueueWorkspace(repoPath, "feat"), { + wrapper: makeWrapper(), + }); + await waitFor(() => result.current.remoteInfo != null); + + await result.current.enqueue.mutateAsync(); + + expect(mockEdgeFn).toHaveBeenCalledWith("enqueue-workspace", { + body: { + repo_full_name: "ziinc/treq", + branch_name: "feat", + action: "enqueue", + }, + }); + }); + + it("blocks enqueue when gh reports PR state is not OPEN", async () => { + const { repoPath } = createTestRepo(false); + addGitHubRemote(repoPath, "git@github.com:ziinc/treq.git"); + ghSpy.mockResolvedValue({ ...OPEN_PR, state: "MERGED" }); + + const { result } = renderHook(() => useEnqueueWorkspace(repoPath, "feat"), { + wrapper: makeWrapper(), + }); + await waitFor( + () => + result.current.remoteInfo != null && + result.current.prInfoGh !== undefined, + ); + + await expect(result.current.enqueue.mutateAsync()).rejects.toThrow( + "No open PR found", + ); + expect(mockEdgeFn).not.toHaveBeenCalled(); + }); + + it("allows dequeue even when gh reports PR state is MERGED", async () => { + const { repoPath } = createTestRepo(false); + addGitHubRemote(repoPath, "git@github.com:ziinc/treq.git"); + ghSpy.mockResolvedValue({ ...OPEN_PR, state: "MERGED" }); + mockEdgeFn.mockResolvedValue({ error: null }); + + const { result } = renderHook(() => useEnqueueWorkspace(repoPath, "feat"), { + wrapper: makeWrapper(), + }); + await waitFor(() => result.current.remoteInfo != null); + + await result.current.dequeue.mutateAsync(); + + expect(mockEdgeFn).toHaveBeenCalledWith( + "enqueue-workspace", + expect.objectContaining({ + body: expect.objectContaining({ action: "dequeue" }), + }), + ); + }); + + it("skips pre-flight and proceeds when gh returns null", async () => { + const { repoPath } = createTestRepo(false); + addGitHubRemote(repoPath, "https://github.com/ziinc/treq.git"); + ghSpy.mockResolvedValue(null); + mockEdgeFn.mockResolvedValue({ error: null }); + + const { result } = renderHook(() => useEnqueueWorkspace(repoPath, "feat"), { + wrapper: makeWrapper(), + }); + await waitFor(() => result.current.remoteInfo != null); + + await result.current.enqueue.mutateAsync(); + expect(mockEdgeFn).toHaveBeenCalled(); + }); + + it("throws when no GitHub remote is detected", async () => { + const { repoPath } = createTestRepo(false); + ghSpy.mockResolvedValue(null); + + const { result } = renderHook(() => useEnqueueWorkspace(repoPath, "feat"), { + wrapper: makeWrapper(), + }); + + await new Promise((r) => setTimeout(r, 200)); + + await expect(result.current.enqueue.mutateAsync()).rejects.toThrow( + "Repository or branch not detected", + ); + expect(mockEdgeFn).not.toHaveBeenCalled(); + }); +}); From 540dd46dbb64d6b9acc95e6892b94d263d5b5fdf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 07:58:37 +0000 Subject: [PATCH 07/42] feat: add GitHub integration panel with Issues and PR CRUD via gh CLI - Add GitHub icon button to WorkspaceSidebar that navigates to github view - Add "github" ViewMode to Dashboard with GitHubPanel overlay - GitHubPanel: split-pane layout with Issues/PRs tabs and open/closed/all filter - Issue CRUD: list, view with comments, create, add comment, close, reopen - PR CRUD: list, view with comments, create, add comment, close, reopen - 12 new Rust impl functions in github.rs calling gh CLI with --repo flag - 12 new Tauri commands in commands/github.rs wired into lib.rs invoke handler - New TypeScript types: GhLabel, GhAuthor, GhIssueComment, GhIssue, GhPullRequest - Split into github-panel/ subdirectory (shared.tsx, IssueDetail.tsx, PrDetail.tsx) to stay within the 500-line component limit --- src-tauri/src/commands/github.rs | 114 ++++++- src-tauri/src/github.rs | 356 ++++++++++++++++++++ src-tauri/src/lib.rs | 12 + src/components/Dashboard.tsx | 29 +- src/components/GitHubPanel.tsx | 285 ++++++++++++++++ src/components/WorkspaceSidebar.tsx | 34 +- src/components/github-panel/IssueDetail.tsx | 243 +++++++++++++ src/components/github-panel/PrDetail.tsx | 264 +++++++++++++++ src/components/github-panel/shared.tsx | 162 +++++++++ src/lib/api-types.ts | 45 +++ src/lib/api.ts | 78 +++++ 11 files changed, 1616 insertions(+), 6 deletions(-) create mode 100644 src/components/GitHubPanel.tsx create mode 100644 src/components/github-panel/IssueDetail.tsx create mode 100644 src/components/github-panel/PrDetail.tsx create mode 100644 src/components/github-panel/shared.tsx diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs index 27a99401..434d063f 100644 --- a/src-tauri/src/commands/github.rs +++ b/src-tauri/src/commands/github.rs @@ -1,5 +1,11 @@ use crate::binary_paths::{detect_binary, get_binary_path, get_extended_path}; -pub use crate::github::{GitRemoteInfo, PrInfo}; +pub use crate::github::{GhIssue, GhPullRequest, GitRemoteInfo, PrInfo}; + +fn gh_bin() -> Result { + get_binary_path("gh") + .or_else(|| detect_binary("gh")) + .ok_or_else(|| "gh CLI not found".to_string()) +} /// Run `gh pr view` for the given branch in the given repo directory. /// Returns None if gh is not installed, not authenticated, or no PR exists. @@ -21,3 +27,109 @@ pub fn get_pr_info_via_gh( pub fn get_git_remote_url(repo_path: String) -> Result, String> { crate::github::get_git_remote_url_impl(&repo_path) } + +#[tauri::command] +pub fn gh_list_issues(repo_full_name: String, state: String) -> Result, String> { + let gh = gh_bin()?; + crate::github::gh_list_issues_impl(&gh, &repo_full_name, &state, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_view_issue(repo_full_name: String, issue_number: u64) -> Result { + let gh = gh_bin()?; + crate::github::gh_view_issue_impl(&gh, &repo_full_name, issue_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_create_issue(repo_full_name: String, title: String, body: String) -> Result { + let gh = gh_bin()?; + crate::github::gh_create_issue_impl(&gh, &repo_full_name, &title, &body, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_create_issue_comment( + repo_full_name: String, + issue_number: u64, + body: String, +) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_create_issue_comment_impl( + &gh, + &repo_full_name, + issue_number, + &body, + &get_extended_path(), + ) +} + +#[tauri::command] +pub fn gh_close_issue(repo_full_name: String, issue_number: u64) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_close_issue_impl(&gh, &repo_full_name, issue_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_reopen_issue(repo_full_name: String, issue_number: u64) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_reopen_issue_impl(&gh, &repo_full_name, issue_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_list_prs(repo_full_name: String, state: String) -> Result, String> { + let gh = gh_bin()?; + crate::github::gh_list_prs_impl(&gh, &repo_full_name, &state, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_view_pr(repo_full_name: String, pr_number: u64) -> Result { + let gh = gh_bin()?; + crate::github::gh_view_pr_impl(&gh, &repo_full_name, pr_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_create_pr_comment( + repo_full_name: String, + pr_number: u64, + body: String, +) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_create_pr_comment_impl( + &gh, + &repo_full_name, + pr_number, + &body, + &get_extended_path(), + ) +} + +#[tauri::command] +pub fn gh_close_pr(repo_full_name: String, pr_number: u64) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_close_pr_impl(&gh, &repo_full_name, pr_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_reopen_pr(repo_full_name: String, pr_number: u64) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_reopen_pr_impl(&gh, &repo_full_name, pr_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_create_pr( + repo_full_name: String, + title: String, + body: String, + base_branch: String, + head_branch: String, +) -> Result { + let gh = gh_bin()?; + crate::github::gh_create_pr_impl( + &gh, + &repo_full_name, + &title, + &body, + &base_branch, + &head_branch, + &get_extended_path(), + ) +} diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs index 441e4ead..2c68472f 100644 --- a/src-tauri/src/github.rs +++ b/src-tauri/src/github.rs @@ -83,6 +83,362 @@ pub fn get_git_remote_url_impl(repo_path: &str) -> Result, Ok(None) } +// ── GitHub Issues / PRs ───────────────────────────────────────────────────── + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct GhLabel { + pub name: String, + pub color: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct GhAuthor { + pub login: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct GhIssueComment { + pub id: String, + pub body: String, + pub author: GhAuthor, + pub created_at: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct GhIssue { + pub number: u64, + pub title: String, + pub state: String, + pub url: String, + pub body: Option, + pub author: GhAuthor, + pub labels: Vec, + pub created_at: String, + pub updated_at: String, + pub comments: Option>, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct GhPullRequest { + pub number: u64, + pub title: String, + pub state: String, + pub url: String, + pub body: Option, + pub author: GhAuthor, + pub labels: Vec, + pub head_ref_name: String, + pub base_ref_name: String, + pub merge_state_status: Option, + pub created_at: String, + pub updated_at: String, + pub comments: Option>, +} + +fn run_gh( + gh_path: &str, + args: &[&str], + extended_path: &str, +) -> Result { + std::process::Command::new(gh_path) + .args(args) + .env("PATH", extended_path) + .output() + .map_err(|e| e.to_string()) +} + +fn check_gh_output(output: std::process::Output) -> Result, String> { + if output.status.success() { + Ok(output.stdout) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("gh exited with error: {stderr}")) + } +} + +fn parse_number_from_url(url: &str) -> Option { + url.trim().rsplit('/').next()?.parse().ok() +} + +pub fn gh_list_issues_impl( + gh_path: &str, + repo_full_name: &str, + state: &str, + extended_path: &str, +) -> Result, String> { + let out = run_gh( + gh_path, + &[ + "issue", + "list", + "--repo", + repo_full_name, + "--state", + state, + "--json", + "number,title,state,url,author,labels,createdAt,updatedAt", + "--limit", + "100", + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse gh output: {e}")) +} + +pub fn gh_view_issue_impl( + gh_path: &str, + repo_full_name: &str, + issue_number: u64, + extended_path: &str, +) -> Result { + let num = issue_number.to_string(); + let out = run_gh( + gh_path, + &[ + "issue", + "view", + &num, + "--repo", + repo_full_name, + "--json", + "number,title,state,url,body,author,labels,createdAt,updatedAt,comments", + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse gh output: {e}")) +} + +pub fn gh_create_issue_impl( + gh_path: &str, + repo_full_name: &str, + title: &str, + body: &str, + extended_path: &str, +) -> Result { + let out = run_gh( + gh_path, + &[ + "issue", + "create", + "--repo", + repo_full_name, + "--title", + title, + "--body", + body, + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + let text = String::from_utf8_lossy(&bytes); + for line in text.lines() { + if let Some(n) = parse_number_from_url(line) { + return Ok(n); + } + } + Err(format!("Could not parse issue number from output: {text}")) +} + +pub fn gh_create_issue_comment_impl( + gh_path: &str, + repo_full_name: &str, + issue_number: u64, + body: &str, + extended_path: &str, +) -> Result<(), String> { + let num = issue_number.to_string(); + let out = run_gh( + gh_path, + &[ + "issue", + "comment", + &num, + "--repo", + repo_full_name, + "--body", + body, + ], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_close_issue_impl( + gh_path: &str, + repo_full_name: &str, + issue_number: u64, + extended_path: &str, +) -> Result<(), String> { + let num = issue_number.to_string(); + let out = run_gh( + gh_path, + &["issue", "close", &num, "--repo", repo_full_name], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_reopen_issue_impl( + gh_path: &str, + repo_full_name: &str, + issue_number: u64, + extended_path: &str, +) -> Result<(), String> { + let num = issue_number.to_string(); + let out = run_gh( + gh_path, + &["issue", "reopen", &num, "--repo", repo_full_name], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_list_prs_impl( + gh_path: &str, + repo_full_name: &str, + state: &str, + extended_path: &str, +) -> Result, String> { + let out = run_gh( + gh_path, + &[ + "pr", + "list", + "--repo", + repo_full_name, + "--state", + state, + "--json", + "number,title,state,url,author,labels,headRefName,baseRefName,createdAt,updatedAt", + "--limit", + "100", + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse gh output: {e}")) +} + +pub fn gh_view_pr_impl( + gh_path: &str, + repo_full_name: &str, + pr_number: u64, + extended_path: &str, +) -> Result { + let num = pr_number.to_string(); + let out = run_gh( + gh_path, + &[ + "pr", + "view", + &num, + "--repo", + repo_full_name, + "--json", + "number,title,state,url,body,author,labels,headRefName,baseRefName,mergeStateStatus,createdAt,updatedAt,comments", + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse gh output: {e}")) +} + +pub fn gh_create_pr_comment_impl( + gh_path: &str, + repo_full_name: &str, + pr_number: u64, + body: &str, + extended_path: &str, +) -> Result<(), String> { + let num = pr_number.to_string(); + let out = run_gh( + gh_path, + &[ + "pr", + "comment", + &num, + "--repo", + repo_full_name, + "--body", + body, + ], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_close_pr_impl( + gh_path: &str, + repo_full_name: &str, + pr_number: u64, + extended_path: &str, +) -> Result<(), String> { + let num = pr_number.to_string(); + let out = run_gh( + gh_path, + &["pr", "close", &num, "--repo", repo_full_name], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_reopen_pr_impl( + gh_path: &str, + repo_full_name: &str, + pr_number: u64, + extended_path: &str, +) -> Result<(), String> { + let num = pr_number.to_string(); + let out = run_gh( + gh_path, + &["pr", "reopen", &num, "--repo", repo_full_name], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_create_pr_impl( + gh_path: &str, + repo_full_name: &str, + title: &str, + body: &str, + base_branch: &str, + head_branch: &str, + extended_path: &str, +) -> Result { + let out = run_gh( + gh_path, + &[ + "pr", + "create", + "--repo", + repo_full_name, + "--title", + title, + "--body", + body, + "--base", + base_branch, + "--head", + head_branch, + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + let text = String::from_utf8_lossy(&bytes); + for line in text.lines() { + if let Some(n) = parse_number_from_url(line) { + return Ok(n); + } + } + Err(format!("Could not parse PR number from output: {text}")) +} + /// Run `gh pr view` for the given branch in the given repo directory. /// Returns None if gh is not installed, not authenticated, or no PR exists. /// The `gh_path` argument is the resolved path to the gh binary. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 31957293..9d2023b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -505,6 +505,18 @@ pub fn run() { commands::dry_run_home_repo_rebase, commands::get_git_remote_url, commands::get_pr_info_via_gh, + commands::gh_list_issues, + commands::gh_view_issue, + commands::gh_create_issue, + commands::gh_create_issue_comment, + commands::gh_close_issue, + commands::gh_reopen_issue, + commands::gh_list_prs, + commands::gh_view_pr, + commands::gh_create_pr_comment, + commands::gh_close_pr, + commands::gh_reopen_pr, + commands::gh_create_pr, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 352c1c36..d6e79a23 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -22,6 +22,7 @@ import type { ClaudeSessionData } from "./terminal/types"; import { SettingsPage } from "./SettingsPage"; import { MergePreviewPage } from "./MergePreviewPage"; +import { GitHubPanel } from "./GitHubPanel"; import { useToast } from "./ui/toast"; import { useKeyboardShortcut } from "../hooks/useKeyboard"; import { useWorkspaceHierarchy } from "../hooks/useWorkspaceHierarchy"; @@ -62,7 +63,12 @@ import { import { Onboarding } from "./Onboarding"; import type { BranchListItem } from "./TargetBranchSelector"; -type ViewMode = "session" | "show-workspace" | "settings" | "merge-preview"; +type ViewMode = + | "session" + | "show-workspace" + | "settings" + | "merge-preview" + | "github"; type SessionOpenOptions = { initialPrompt?: string; @@ -138,6 +144,10 @@ export const Dashboard: React.FC = ({ setViewMode("settings"); }, []); + const openGitHub = useCallback(() => { + setViewMode("github"); + }, []); + const handleOpenMergePreview = useCallback(() => { if (selectedWorkspace) { setMergeWorkspace(selectedWorkspace); @@ -978,12 +988,15 @@ export const Dashboard: React.FC = ({ handleCreateSessionFromSidebar(workspace.id) } onStartShell={handleStartShellFromSidebar} + onOpenGitHub={openGitHub} currentPage={ viewMode === "settings" ? "settings" - : viewMode === "session" || viewMode === "show-workspace" - ? "session" - : undefined + : viewMode === "github" + ? "github" + : viewMode === "session" || viewMode === "show-workspace" + ? "session" + : undefined } /> @@ -1152,6 +1165,14 @@ export const Dashboard: React.FC = ({ /> )} + {/* GitHub Panel */} + {viewMode === "github" && ( + + )} + {/* Merge Preview View */} {viewMode === "merge-preview" && mergeWorkspace && ( void; +} + +const FILTERS: { label: string; value: StateFilter }[] = [ + { label: "Open", value: "open" }, + { label: "Closed", value: "closed" }, + { label: "All", value: "all" }, +]; + +export const GitHubPanel: React.FC = ({ + repoPath, + onClose, +}) => { + const { data: remoteInfo, isLoading: remoteLoading } = + useGitRemoteInfo(repoPath); + const [activeTab, setActiveTab] = useState("issues"); + const [issueFilter, setIssueFilter] = useState("open"); + const [prFilter, setPrFilter] = useState("open"); + const [selectedIssue, setSelectedIssue] = useState(null); + const [selectedPr, setSelectedPr] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); + + const repoFullName = remoteInfo?.full_name ?? ""; + + const { + data: issues = [], + isLoading: issuesLoading, + refetch: refetchIssues, + } = useQuery({ + queryKey: ["gh-issues", repoFullName, issueFilter], + queryFn: () => ghListIssues(repoFullName, issueFilter), + enabled: !!repoFullName && activeTab === "issues", + }); + + const { + data: prs = [], + isLoading: prsLoading, + refetch: refetchPrs, + } = useQuery({ + queryKey: ["gh-prs", repoFullName, prFilter], + queryFn: () => ghListPrs(repoFullName, prFilter), + enabled: !!repoFullName && activeTab === "prs", + }); + + const isListLoading = activeTab === "issues" ? issuesLoading : prsLoading; + const currentFilter = activeTab === "issues" ? issueFilter : prFilter; + const setCurrentFilter = + activeTab === "issues" ? setIssueFilter : setPrFilter; + + const showDetail = + (activeTab === "issues" && selectedIssue !== null) || + (activeTab === "prs" && selectedPr !== null); + + function handleTabChange(v: string) { + setActiveTab(v as TabValue); + setSelectedIssue(null); + setSelectedPr(null); + setShowCreateForm(false); + } + + function handleSelectIssue(n: number) { + setSelectedIssue(n); + setShowCreateForm(false); + } + + function handleSelectPr(n: number) { + setSelectedPr(n); + setShowCreateForm(false); + } + + function handleNewClick() { + setShowCreateForm(true); + setSelectedIssue(null); + setSelectedPr(null); + } + + return ( +
+ {/* List panel */} +
+
+
+ +
+

GitHub

+ {remoteLoading ? ( + Loading… + ) : remoteInfo ? ( + + {remoteInfo.full_name} + + ) : ( + + No GitHub remote + + )} +
+
+ +
+ +
+ + + + Issues + + + Pull Requests + + + +
+ +
+
+ {FILTERS.map((btn) => ( + + ))} +
+
+ + {remoteInfo && ( + + )} +
+ + {showCreateForm && remoteInfo && ( +
+ {activeTab === "issues" ? ( + { + setShowCreateForm(false); + setSelectedIssue(n); + }} + onCancel={() => setShowCreateForm(false)} + /> + ) : ( + { + setShowCreateForm(false); + setSelectedPr(n); + }} + onCancel={() => setShowCreateForm(false)} + /> + )} +
+ )} + +
+ {!remoteInfo && !remoteLoading && ( +
+ +

+ No GitHub remote detected for this repository. +

+
+ )} + + {remoteInfo && isListLoading && ( +
+ +
+ )} + + {remoteInfo && !isListLoading && activeTab === "issues" && ( + <> + {issues.length === 0 ? ( + + ) : ( + issues.map((issue) => ( + handleSelectIssue(issue.number)} + /> + )) + )} + + )} + + {remoteInfo && !isListLoading && activeTab === "prs" && ( + <> + {prs.length === 0 ? ( + + ) : ( + prs.map((pr) => ( + handleSelectPr(pr.number)} + /> + )) + )} + + )} +
+
+ + {/* Detail panel */} + {showDetail && ( +
+ {activeTab === "issues" && selectedIssue !== null && ( + setSelectedIssue(null)} + /> + )} + {activeTab === "prs" && selectedPr !== null && ( + setSelectedPr(null)} + /> + )} +
+ )} +
+ ); +}; diff --git a/src/components/WorkspaceSidebar.tsx b/src/components/WorkspaceSidebar.tsx index 72fde8eb..837fe636 100644 --- a/src/components/WorkspaceSidebar.tsx +++ b/src/components/WorkspaceSidebar.tsx @@ -1,7 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import { memo, useCallback, useMemo, useState } from "react"; import { DragDropContext, type DropResult, Droppable } from "@hello-pangea/dnd"; -import { GitBranch, Home, Search, Settings, Trash2 } from "lucide-react"; +import { + GitBranch, + Github, + Home, + Search, + Settings, + Trash2, +} from "lucide-react"; import { type Workspace, getWorkspaceStatus, @@ -56,6 +63,7 @@ interface WorkspaceSidebarProps { navigateToDashboard?: () => void; onOpenCommandPalette?: () => void; onOpenBranchSwitcher?: () => void; + onOpenGitHub?: () => void; currentPage?: string; onAddBefore?: (workspace: Workspace) => void; onAddAfter?: (workspace: Workspace) => void; @@ -78,6 +86,7 @@ export const WorkspaceSidebar: React.FC = memo( openSettings, onOpenCommandPalette, onOpenBranchSwitcher, + onOpenGitHub, currentPage, onAddAfter, onMoveWorkspace, @@ -244,6 +253,29 @@ export const WorkspaceSidebar: React.FC = memo( ⌘ + K + {onOpenGitHub && ( + + + + + GitHub + + )} {openSettings && ( diff --git a/src/components/github-panel/IssueDetail.tsx b/src/components/github-panel/IssueDetail.tsx new file mode 100644 index 00000000..e8ca6937 --- /dev/null +++ b/src/components/github-panel/IssueDetail.tsx @@ -0,0 +1,243 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, MessageSquare, X } from "lucide-react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Textarea } from "../ui/textarea"; +import { + ghCloseIssue, + ghCreateIssue, + ghCreateIssueComment, + ghReopenIssue, + ghViewIssue, +} from "../../lib/api"; +import { formatDate, LabelChip, StateChip } from "./shared"; + +export function IssueDetailPanel({ + repoFullName, + issueNumber, + onClose, +}: { + repoFullName: string; + issueNumber: number; + onClose: () => void; +}) { + const qc = useQueryClient(); + const [commentBody, setCommentBody] = useState(""); + + const { data: issue, isLoading } = useQuery({ + queryKey: ["gh-issue", repoFullName, issueNumber], + queryFn: () => ghViewIssue(repoFullName, issueNumber), + }); + + const addComment = useMutation({ + mutationFn: () => + ghCreateIssueComment(repoFullName, issueNumber, commentBody), + onSuccess: () => { + setCommentBody(""); + void qc.invalidateQueries({ + queryKey: ["gh-issue", repoFullName, issueNumber], + }); + }, + }); + + const closeIssue = useMutation({ + mutationFn: () => ghCloseIssue(repoFullName, issueNumber), + onSuccess: () => { + void qc.invalidateQueries({ + queryKey: ["gh-issue", repoFullName, issueNumber], + }); + void qc.invalidateQueries({ queryKey: ["gh-issues", repoFullName] }); + }, + }); + + const reopenIssue = useMutation({ + mutationFn: () => ghReopenIssue(repoFullName, issueNumber), + onSuccess: () => { + void qc.invalidateQueries({ + queryKey: ["gh-issue", repoFullName, issueNumber], + }); + void qc.invalidateQueries({ queryKey: ["gh-issues", repoFullName] }); + }, + }); + + return ( +
+
+ + Issue #{issueNumber} + + +
+ + {isLoading && ( +
+ +
+ )} + + {issue && ( +
+
+

{issue.title}

+
+ + + #{issue.number} opened by {issue.author.login} on{" "} + {formatDate(issue.created_at)} + +
+ {issue.labels.length > 0 && ( +
+ {issue.labels.map((l) => ( + + ))} +
+ )} +
+ + {issue.body && ( +
+ {issue.body} +
+ )} + + {(issue.comments ?? []).length > 0 && ( +
+

+ + Comments ({issue.comments!.length}) +

+ {issue.comments!.map((c) => ( +
+
+ {c.author.login} + · + {formatDate(c.created_at)} +
+

{c.body}

+
+ ))} +
+ )} + +
+

+ Add Comment +

+