diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 843ce01..986784a 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -9,6 +9,8 @@ import { deleteSession, listSessions, readSession, searchSessions, sessionMtime, import { loadState, saveState } from "./state.ts"; import * as auth from "./auth.ts"; import { gitAddWorktree, gitBranches, gitCheckout, gitCommit, gitDiff, gitHunks, gitPushAndCreatePr, gitStageFile, gitStageHunk, gitStatus } from "./git.ts"; +import { getRepoHostContext } from "./repoHost.ts"; +import { repoHostContextSchema } from "../shared/repo-host-types.ts"; import { installPackage, listPackages, removePackage, updatePackage } from "./packages.ts"; import { listSkills } from "./skills.ts"; import { listProjectFiles } from "./files.ts"; @@ -1019,6 +1021,15 @@ const handlers: HandlerMap = { } }, + repoHostContext: async ({ projectDir }) => { + try { + const context = await getRepoHostContext(projectDir); + return { context: context ? repoHostContextSchema.safeParse(context).data ?? null : null }; + } catch { + return { context: null }; + } + }, + listCommands: async ({ projectDir }) => { try { const pi = await ensurePi(projectDir); diff --git a/apps/desktop/src/main/repoHost.test.ts b/apps/desktop/src/main/repoHost.test.ts new file mode 100644 index 0000000..6e22006 --- /dev/null +++ b/apps/desktop/src/main/repoHost.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { detectHostFromRemoteUrl, issueNumberFromBranch, parseAheadBehind, parseNameStatus } from "./repoHost.ts"; + +test("detectHostFromRemoteUrl recognizes github.com over SSH and HTTPS", () => { + expect(detectHostFromRemoteUrl("git@github.com:owner/repo.git")).toBe("github"); + expect(detectHostFromRemoteUrl("https://github.com/owner/repo.git")).toBe("github"); +}); + +test("detectHostFromRemoteUrl recognizes gitlab.com and self-hosted GitLab", () => { + expect(detectHostFromRemoteUrl("git@gitlab.com:owner/repo.git")).toBe("gitlab"); + expect(detectHostFromRemoteUrl("https://gitlab.example.corp/owner/repo.git")).toBe("gitlab"); +}); + +test("detectHostFromRemoteUrl returns null for an unrecognized host", () => { + expect(detectHostFromRemoteUrl("https://git.sr.ht/~owner/repo")).toBeNull(); +}); + +test("detectHostFromRemoteUrl uses only the hostname", () => { + expect(detectHostFromRemoteUrl("git@gitlab.com:team/github-tools.git")).toBe("gitlab"); + expect(detectHostFromRemoteUrl("https://github.com/team/gitlab-tools.git")).toBe("github"); +}); + +test("issueNumberFromBranch finds a leading issue number", () => { + expect(issueNumberFromBranch("123-fix-thing")).toBe(123); +}); + +test("issueNumberFromBranch finds a delimited issue number after a prefix", () => { + expect(issueNumberFromBranch("issue-123")).toBe(123); + expect(issueNumberFromBranch("feature/123-fix-thing")).toBe(123); +}); + +test("issueNumberFromBranch returns null when the branch has no issue number", () => { + expect(issueNumberFromBranch("main")).toBeNull(); + expect(issueNumberFromBranch("feature/repo-host-context")).toBeNull(); +}); + +test("parseAheadBehind parses rev-list --left-right --count output", () => { + expect(parseAheadBehind("2\t5")).toEqual({ behindBy: 2, aheadBy: 5 }); +}); + +test("parseAheadBehind returns null for unexpected output", () => { + expect(parseAheadBehind("")).toBeNull(); + expect(parseAheadBehind("not a number\t5")).toBeNull(); +}); + +test("parseNameStatus parses modified, added, and deleted entries", () => { + expect(parseNameStatus("M\tfoo.ts\nA\tbar.ts\nD\tbaz.ts")).toEqual([ + { path: "foo.ts", state: "modified" }, + { path: "bar.ts", state: "added" }, + { path: "baz.ts", state: "deleted" }, + ]); +}); + +test("parseNameStatus parses a rename entry, keeping the new path", () => { + expect(parseNameStatus("R100\told.ts\tnew.ts")).toEqual([{ path: "new.ts", state: "renamed" }]); +}); + +test("parseNameStatus ignores blank lines", () => { + expect(parseNameStatus("M\tfoo.ts\n\n")).toEqual([{ path: "foo.ts", state: "modified" }]); +}); diff --git a/apps/desktop/src/main/repoHost.ts b/apps/desktop/src/main/repoHost.ts new file mode 100644 index 0000000..a1a91a2 --- /dev/null +++ b/apps/desktop/src/main/repoHost.ts @@ -0,0 +1,293 @@ +import { execFile } from "node:child_process"; +import type { + RepoHost, + RepoHostCheck, + RepoHostCompare, + RepoHostCompareFile, + RepoHostComment, + RepoHostContext, + RepoHostLinkedIssue, +} from "../shared/repo-host-types.ts"; +import { repoHostContextSchema } from "../shared/repo-host-types.ts"; + +function run(cmd: string, args: string[], cwd: string): Promise<{ stdout: string; code: number }> { + return new Promise((resolve) => { + execFile(cmd, args, { cwd, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (err, stdout) => { + const code = err && typeof (err as { code?: unknown }).code === "number" ? (err as { code: number }).code : err ? 1 : 0; + resolve({ stdout, code }); + }); + }); +} + +/** github.com and self-hosted GitHub Enterprise both surface "github" somewhere in the remote host name; same idea for GitLab. */ +export function detectHostFromRemoteUrl(remoteUrl: string): RepoHost | null { + const remote = remoteUrl.trim(); + const host = (remote.match(/^[^@\s]+@([^:/\s]+)[:/]/)?.[1] ?? remote.match(/^[a-z]+:\/\/([^/:\s]+)/i)?.[1] ?? "").toLowerCase(); + if (host.includes("github")) return "github"; + if (host.includes("gitlab")) return "gitlab"; + return null; +} + +async function detectHost(projectDir: string): Promise { + const remote = await run("git", ["remote", "get-url", "origin"], projectDir); + if (remote.code !== 0) return null; + return detectHostFromRemoteUrl(remote.stdout.trim()); +} + +/** + * A bare issue has no CLI lookup by branch, unlike a PR/MR. The closest signal + * a branch name gives is a leading or delimited issue number, the convention + * `gh`/`glab` branch-creation helpers and most teams already follow (e.g. + * `123-fix-thing`, `issue-123`, `feature/123-fix-thing`). + */ +export function issueNumberFromBranch(branch: string): number | null { + const match = branch.match(/(?:^|[/_-])(\d+)(?:[/_-]|$)/); + return match ? Number(match[1]) : null; +} + +function safeJson(text: string): T | null { + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} + +/** `git rev-list --left-right --count base...HEAD` prints "\t". */ +export function parseAheadBehind(output: string): { aheadBy: number; behindBy: number } | null { + const parts = output.trim().split(/\s+/); + if (parts.length !== 2) return null; + const behindBy = Number(parts[0]); + const aheadBy = Number(parts[1]); + if (!Number.isFinite(behindBy) || !Number.isFinite(aheadBy)) return null; + return { aheadBy, behindBy }; +} + +const NAME_STATUS: Record = { M: "modified", A: "added", D: "deleted", R: "renamed" }; + +/** `git diff --name-status base HEAD`: one `"X\tpath"` (or `"R100\told\tnew"`) line per file. */ +export function parseNameStatus(output: string): RepoHostCompareFile[] { + const files: RepoHostCompareFile[] = []; + for (const line of output.split("\n")) { + if (!line.trim()) continue; + const [code, ...paths] = line.split("\t"); + const state = NAME_STATUS[(code ?? "").slice(0, 1)] ?? "modified"; + const path = paths[paths.length - 1]; + if (path) files.push({ path, state }); + } + return files; +} + +async function resolveBaseRef(projectDir: string, baseBranch: string): Promise { + for (const candidate of [`origin/${baseBranch}`, baseBranch]) { + const res = await run("git", ["rev-parse", "--verify", "--quiet", candidate], projectDir); + if (res.code === 0) return candidate; + } + return null; +} + +/** Never fetches: a base branch NativePi has not already fetched is quietly left out rather than making a network call on the user's behalf. */ +async function compareToBase(projectDir: string, baseBranch: string): Promise { + const baseRef = await resolveBaseRef(projectDir, baseBranch); + if (!baseRef) return undefined; + const [counts, diff] = await Promise.all([ + run("git", ["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], projectDir), + run("git", ["diff", "--no-color", "--name-status", `${baseRef}...HEAD`], projectDir), + ]); + const aheadBehind = counts.code === 0 ? parseAheadBehind(counts.stdout) : null; + if (!aheadBehind) return undefined; + return { baseRef: baseBranch, ...aheadBehind, files: diff.code === 0 ? parseNameStatus(diff.stdout) : [] }; +} + +interface GhPr { + number: number; + title: string; + body?: string; + url: string; + state: string; + isDraft?: boolean; + author?: { login?: string }; + baseRefName?: string; + comments?: { author?: { login?: string }; body: string; createdAt?: string; url?: string }[]; + reviews?: { author?: { login?: string }; body?: string; state?: string; submittedAt?: string }[]; + statusCheckRollup?: { name?: string; context?: string; workflowName?: string; conclusion?: string; state?: string; url?: string; detailsUrl?: string; targetUrl?: string }[]; + closingIssuesReferences?: { number: number; title: string; url: string }[]; +} + +const GH_PR_FIELDS = + "number,title,body,url,state,isDraft,author,baseRefName,comments,reviews,statusCheckRollup,closingIssuesReferences"; + +function ghChecks(pr: GhPr): RepoHostCheck[] { + return (pr.statusCheckRollup ?? []).map((check) => ({ + name: check.name ?? check.context ?? check.workflowName ?? "check", + status: check.conclusion ?? check.state ?? "pending", + url: check.detailsUrl ?? check.targetUrl ?? check.url, + })); +} + +function ghComments(pr: GhPr): RepoHostComment[] { + const comments: RepoHostComment[] = (pr.comments ?? []).map((c) => ({ + author: c.author?.login, + body: c.body, + createdAt: c.createdAt, + url: c.url, + })); + const reviews: RepoHostComment[] = (pr.reviews ?? []) + .filter((r) => r.body || r.state) + .map((r) => ({ author: r.author?.login, body: r.body ?? "", createdAt: r.submittedAt, reviewState: r.state })); + return [...reviews, ...comments].sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "")); +} + +async function githubPr(projectDir: string): Promise { + const res = await run("gh", ["pr", "view", "--json", GH_PR_FIELDS], projectDir); + if (res.code !== 0) return null; + const pr = safeJson(res.stdout); + if (!pr) return null; + return repoHostContextSchema.safeParse({ + host: "github", + kind: "pr", + number: pr.number, + title: pr.title, + body: pr.body, + url: pr.url, + state: pr.state, + draft: pr.isDraft, + author: pr.author?.login, + comments: ghComments(pr), + checks: ghChecks(pr), + linkedIssues: (pr.closingIssuesReferences ?? []) as RepoHostLinkedIssue[], + compare: pr.baseRefName ? await compareToBase(projectDir, pr.baseRefName) : undefined, + }).data ?? null; +} + +interface GhIssue { + number: number; + title: string; + body?: string; + url: string; + state: string; + author?: { login?: string }; + comments?: { author?: { login?: string }; body: string; createdAt?: string; url?: string }[]; +} + +async function githubIssue(projectDir: string, number: number): Promise { + const res = await run("gh", ["issue", "view", String(number), "--json", "number,title,body,url,state,author,comments"], projectDir); + if (res.code !== 0) return null; + const issue = safeJson(res.stdout); + if (!issue) return null; + return repoHostContextSchema.safeParse({ + host: "github", + kind: "issue", + number: issue.number, + title: issue.title, + body: issue.body, + url: issue.url, + state: issue.state, + author: issue.author?.login, + comments: (issue.comments ?? []).map((c) => ({ author: c.author?.login, body: c.body, createdAt: c.createdAt, url: c.url })), + checks: [], + linkedIssues: [], + }).data ?? null; +} + +interface GlabMr { + iid: number; + title: string; + description?: string; + web_url: string; + state: string; + draft?: boolean; + author?: { username?: string }; + target_branch?: string; + head_pipeline?: { status?: string; detailed_status?: { text?: string }; web_url?: string }; +} + +interface GlabNote { + author?: { username?: string }; + body: string; + created_at?: string; +} + +async function gitlabMr(projectDir: string): Promise { + const res = await run("glab", ["mr", "view", "--output", "json"], projectDir); + if (res.code !== 0) return null; + const mr = safeJson(res.stdout); + if (!mr) return null; + + const notes = await run("glab", ["api", `projects/:id/merge_requests/${mr.iid}/notes`], projectDir); + const rawNotes = notes.code === 0 ? (safeJson(notes.stdout) ?? []) : []; + + return repoHostContextSchema.safeParse({ + host: "gitlab", + kind: "pr", + number: mr.iid, + title: mr.title, + body: mr.description, + url: mr.web_url, + state: mr.state, + draft: mr.draft, + author: mr.author?.username, + comments: rawNotes.map((n) => ({ author: n.author?.username, body: n.body, createdAt: n.created_at })), + checks: mr.head_pipeline + ? [{ name: "pipeline", status: mr.head_pipeline.status ?? mr.head_pipeline.detailed_status?.text ?? "pending", url: mr.head_pipeline.web_url }] + : [], + linkedIssues: [], + compare: mr.target_branch ? await compareToBase(projectDir, mr.target_branch) : undefined, + }).data ?? null; +} + +interface GlabIssue { + iid: number; + title: string; + description?: string; + web_url: string; + state: string; + author?: { username?: string }; +} + +async function gitlabIssue(projectDir: string, number: number): Promise { + const res = await run("glab", ["issue", "view", String(number), "--output", "json"], projectDir); + if (res.code !== 0) return null; + const issue = safeJson(res.stdout); + if (!issue) return null; + return repoHostContextSchema.safeParse({ + host: "gitlab", + kind: "issue", + number: issue.iid, + title: issue.title, + body: issue.description, + url: issue.web_url, + state: issue.state, + author: issue.author?.username, + comments: [], + checks: [], + linkedIssues: [], + }).data ?? null; +} + +/** + * The PR/MR NativePi presents is only ever what the user's own `gh`/`glab` + * already resolves for the current branch. When neither CLI finds one, a + * branch-name issue number is tried as a fallback; anything short of that + * (no CLI installed, not authenticated, no match) is quietly `null` rather + * than an error state, matching how the Changes pane treats "not a repo". + */ +export async function getRepoHostContext(projectDir: string): Promise { + const host = await detectHost(projectDir); + if (!host) return null; + + const branchRes = await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], projectDir); + const branch = branchRes.code === 0 ? branchRes.stdout.trim() : ""; + + if (host === "github") { + const pr = await githubPr(projectDir); + if (pr) return pr; + const issueNumber = issueNumberFromBranch(branch); + return issueNumber ? await githubIssue(projectDir, issueNumber) : null; + } + + const mr = await gitlabMr(projectDir); + if (mr) return mr; + const issueNumber = issueNumberFromBranch(branch); + return issueNumber ? await gitlabIssue(projectDir, issueNumber) : null; +} diff --git a/apps/desktop/src/renderer/components/ContextPane.tsx b/apps/desktop/src/renderer/components/ContextPane.tsx index f66767a..0724f8b 100644 --- a/apps/desktop/src/renderer/components/ContextPane.tsx +++ b/apps/desktop/src/renderer/components/ContextPane.tsx @@ -24,6 +24,7 @@ import FileTypeIcon from "./FileTypeIcon.tsx"; import FileContextMenu from "./FileContextMenu.tsx"; import { ExtensionPanels } from "./ExtensionSlots.tsx"; import CommitDialog from "./CommitDialog.tsx"; +import RepoHostPanel from "./RepoHostPanel.tsx"; export default function ContextPane({ overlay = false, onClose }: { overlay?: boolean; onClose?: () => void }) { const git = useAppStore((s) => s.git); @@ -101,6 +102,8 @@ export default function ContextPane({ overlay = false, onClose }: { overlay?: bo + + {git.files.length === 0 ? (

Working tree clean.

) : ( diff --git a/apps/desktop/src/renderer/components/RepoHostPanel.tsx b/apps/desktop/src/renderer/components/RepoHostPanel.tsx new file mode 100644 index 0000000..d5675fd --- /dev/null +++ b/apps/desktop/src/renderer/components/RepoHostPanel.tsx @@ -0,0 +1,215 @@ +import { useState } from "react"; +import { ArrowSquareOutIcon } from "@phosphor-icons/react/ArrowSquareOut"; +import { CaretDownIcon } from "@phosphor-icons/react/CaretDown"; +import { CaretRightIcon } from "@phosphor-icons/react/CaretRight"; +import { CheckCircleIcon } from "@phosphor-icons/react/CheckCircle"; +import { CircleDashedIcon } from "@phosphor-icons/react/CircleDashed"; +import { GitPullRequestIcon } from "@phosphor-icons/react/GitPullRequest"; +import { XCircleIcon } from "@phosphor-icons/react/XCircle"; +import type { RepoHostCheck, RepoHostCompareFile, RepoHostContext } from "../../shared/repo-host-types.ts"; +import { useAppStore } from "../lib/store.ts"; +import { rpc } from "../lib/rpc.ts"; +import { Button } from "@/components/ui/button.tsx"; +import { cn } from "@/lib/utils.ts"; + +const PASSING_CHECK = new Set(["success", "neutral", "skipped"]); +const FAILING_CHECK = new Set(["failure", "error", "cancelled", "timed_out", "action_required"]); + +export default function RepoHostPanel() { + const context = useAppStore((s) => s.repoHost); + const insertIntoComposer = useAppStore((s) => s.insertIntoComposer); + const [open, setOpen] = useState>({}); + + if (!context) return null; + const toggle = (key: string) => setOpen((s) => ({ ...s, [key]: !s[key] })); + + return ( +
+ + + {context.author ? ( +

by {context.author}

+ ) : null} + + {context.body ? ( +
toggle("body")} + onInsert={() => insertIntoComposer(context.body ?? "")} + > +

{context.body}

+
+ ) : null} + + {context.checks.length > 0 ? ( +
toggle("checks")}> +
    + {context.checks.map((check, i) => ( + + ))} +
+
+ ) : null} + + {context.comments.length > 0 ? ( +
toggle("comments")}> +
    + {context.comments.map((comment, i) => ( +
  • +
    + {comment.author ?? "someone"} + {comment.reviewState ? {comment.reviewState.toLowerCase().replace(/_/g, " ")} : null} +
    + +
    + {comment.body ?

    {comment.body}

    : null} +
  • + ))} +
+
+ ) : null} + + {context.linkedIssues.length > 0 ? ( +
toggle("issues")}> +
    + {context.linkedIssues.map((issue) => ( +
  • + +
  • + ))} +
+
+ ) : null} + + {context.compare ? ( +
toggle("compare")} + > +

+ {context.compare.aheadBy} ahead, {context.compare.behindBy} behind · {context.compare.files.length} file + {context.compare.files.length === 1 ? "" : "s"} changed +

+ {context.compare.files.length > 0 ? ( +
    + {context.compare.files.map((file) => ( + + ))} +
+ ) : null} +
+ ) : null} +
+ ); +} + +function Section({ + title, + expanded, + onToggle, + onInsert, + children, +}: { + title: string; + expanded: boolean; + onToggle: () => void; + onInsert?: () => void; + children: React.ReactNode; +}) { + return ( +
+
+ + {onInsert ? ( + + ) : null} +
+ {expanded ? children : null} +
+ ); +} + +function StateBadge({ context }: { context: RepoHostContext }) { + const label = context.draft ? "Draft" : context.state; + const color = + context.state.toLowerCase() === "merged" + ? "text-info" + : context.state.toLowerCase() === "closed" + ? "text-destructive" + : context.draft + ? "text-muted-foreground" + : "text-success"; + return {label}; +} + +function CheckRow({ check }: { check: RepoHostCheck }) { + const status = check.status.toLowerCase(); + const Icon = PASSING_CHECK.has(status) ? CheckCircleIcon : FAILING_CHECK.has(status) ? XCircleIcon : CircleDashedIcon; + const color = PASSING_CHECK.has(status) ? "text-success" : FAILING_CHECK.has(status) ? "text-destructive" : "text-muted-foreground"; + return ( +
  • + +
  • + ); +} + +function CompareFileRow({ file }: { file: RepoHostCompareFile }) { + const badge = file.state === "added" ? "A" : file.state === "deleted" ? "D" : file.state === "renamed" ? "R" : "M"; + const color = + file.state === "added" ? "text-success" : file.state === "deleted" ? "text-destructive" : file.state === "renamed" ? "text-info" : "text-warning"; + return ( +
  • + {badge} + {file.path} +
  • + ); +} diff --git a/apps/desktop/src/renderer/lib/store/internals.ts b/apps/desktop/src/renderer/lib/store/internals.ts index 72dd39d..27da65c 100644 --- a/apps/desktop/src/renderer/lib/store/internals.ts +++ b/apps/desktop/src/renderer/lib/store/internals.ts @@ -155,6 +155,7 @@ export function warmProject(set: SetState, get: GetState, path: string): void { }); void get().refreshGit(); + void get().refreshRepoHost(); void loadGraphicalExtensions(path).then((res) => { if (get().activeProjectPath !== path) return; diff --git a/apps/desktop/src/renderer/lib/store/projectContext.ts b/apps/desktop/src/renderer/lib/store/projectContext.ts index 281ba05..43b924c 100644 --- a/apps/desktop/src/renderer/lib/store/projectContext.ts +++ b/apps/desktop/src/renderer/lib/store/projectContext.ts @@ -7,6 +7,7 @@ import { NO_EXTENSION_UI_STATE, type ProjectContextSlice, type SliceCreator } fr export const createProjectContextSlice: SliceCreator = (set, get) => ({ git: null, + repoHost: undefined, extPrompts: [], extensionPromptsByProject: {}, extStatuses: {}, @@ -32,6 +33,14 @@ export const createProjectContextSlice: SliceCreator = (set } }, + refreshRepoHost: async () => { + const path = get().activeProjectPath; + if (!path) return; + const { context } = await rpc.request.repoHostContext({ projectDir: path }); + if (get().activeProjectPath !== path) return; + set({ repoHost: context }); + }, + switchBranch: async (branch, create) => { const path = get().activeProjectPath; if (!path) return { ok: false, error: "No project is open." }; @@ -39,6 +48,7 @@ export const createProjectContextSlice: SliceCreator = (set if (!res.ok) return res; if (get().activeProjectPath === path) { await get().refreshGit(); + await get().refreshRepoHost(); showHint(branch); } return res; diff --git a/apps/desktop/src/renderer/lib/store/types.ts b/apps/desktop/src/renderer/lib/store/types.ts index e2a6855..12caa80 100644 --- a/apps/desktop/src/renderer/lib/store/types.ts +++ b/apps/desktop/src/renderer/lib/store/types.ts @@ -19,6 +19,7 @@ import type { ThinkingLevel, } from "../../../shared/pi-types.ts"; import type { PiSettings } from "../../../shared/pi-settings.ts"; +import type { RepoHostContext } from "../../../shared/repo-host-types.ts"; import type { TuiHostFrame, TuiSurface } from "../../../shared/tui-frames.ts"; import type { LoadedExtension } from "../extensionHost.ts"; import type { KeybindingOverrides, ShortcutId } from "../shortcuts.ts"; @@ -230,6 +231,8 @@ export interface AuthSlice { /** What the project looks like right now: working tree, and loaded extensions. */ export interface ProjectContextSlice { git: GitStatus | null; + /** The PR/MR (or, failing that, issue) for the current branch. `undefined` while loading. */ + repoHost: RepoHostContext | null | undefined; extPrompts: ExtensionPrompt[]; /** Pending Pi extension requests, retained only until their project is opened or the run settles. */ extensionPromptsByProject: Record; @@ -244,6 +247,7 @@ export interface ProjectContextSlice { extUiState: ExtensionUiState; refreshGit: () => Promise; + refreshRepoHost: () => Promise; switchBranch: (branch: string, create: boolean) => Promise<{ ok: boolean; error?: string }>; reloadExtensions: () => Promise; respondExtension: (value: { value?: string; confirmed?: boolean; cancel?: boolean }) => void; diff --git a/apps/desktop/src/renderer/lib/store/workspace.ts b/apps/desktop/src/renderer/lib/store/workspace.ts index 0aafe27..fe9d57c 100644 --- a/apps/desktop/src/renderer/lib/store/workspace.ts +++ b/apps/desktop/src/renderer/lib/store/workspace.ts @@ -99,6 +99,7 @@ export const createWorkspaceSlice: SliceCreator = (set, get) => trustPrompt: null, trust: null, git: null, + repoHost: undefined, extPrompts, extStatuses: {}, extWidgets: {}, diff --git a/apps/desktop/src/shared/repo-host-types.ts b/apps/desktop/src/shared/repo-host-types.ts new file mode 100644 index 0000000..a652e3b --- /dev/null +++ b/apps/desktop/src/shared/repo-host-types.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +/** + * Repo-host context: the GitHub or GitLab pull request / merge request (or, if + * neither exists yet, issue) associated with the current branch, read through + * the user's own `gh`/`glab` CLI authentication. NativePi does not talk to + * GitHub or GitLab APIs directly and does not own any of this data — it is a + * read-only presentation of what the CLI already reports. + */ + +export type RepoHost = "github" | "gitlab"; + +export interface RepoHostCheck { + name: string; + /** e.g. "success", "failure", "pending", "skipped", "neutral" */ + status: string; + url?: string; +} + +export interface RepoHostComment { + author?: string; + body: string; + createdAt?: string; + url?: string; + /** Present for a review rather than a plain comment, e.g. "APPROVED". */ + reviewState?: string; +} + +export interface RepoHostLinkedIssue { + number: number; + title: string; + url: string; +} + +export interface RepoHostCompareFile { + path: string; + state: "modified" | "added" | "deleted" | "renamed"; +} + +export interface RepoHostCompare { + baseRef: string; + aheadBy: number; + behindBy: number; + files: RepoHostCompareFile[]; +} + +export interface RepoHostContext { + host: RepoHost; + kind: "pr" | "issue"; + number: number; + title: string; + body?: string; + url: string; + state: string; + draft?: boolean; + author?: string; + comments: RepoHostComment[]; + checks: RepoHostCheck[]; + linkedIssues: RepoHostLinkedIssue[]; + compare?: RepoHostCompare; +} + +export const repoHostContextSchema = z.object({ + host: z.enum(["github", "gitlab"]), + kind: z.enum(["pr", "issue"]), + number: z.number().int(), + title: z.string(), + body: z.string().optional(), + url: z.string(), + state: z.string(), + draft: z.boolean().optional(), + author: z.string().optional(), + comments: z.array(z.object({ author: z.string().optional(), body: z.string(), createdAt: z.string().optional(), url: z.string().optional(), reviewState: z.string().optional() })), + checks: z.array(z.object({ name: z.string(), status: z.string(), url: z.string().optional() })), + linkedIssues: z.array(z.object({ number: z.number().int(), title: z.string(), url: z.string() })), + compare: z.object({ baseRef: z.string(), aheadBy: z.number(), behindBy: z.number(), files: z.array(z.object({ path: z.string(), state: z.enum(["modified", "added", "deleted", "renamed"]) })) }).optional(), +}).passthrough(); diff --git a/apps/desktop/src/shared/rpc-schema.ts b/apps/desktop/src/shared/rpc-schema.ts index a2b09e1..0492b99 100644 --- a/apps/desktop/src/shared/rpc-schema.ts +++ b/apps/desktop/src/shared/rpc-schema.ts @@ -24,6 +24,7 @@ import type { SkillInfo, ThinkingLevel, } from "./pi-types.ts"; +import type { RepoHostContext } from "./repo-host-types.ts"; import type { TuiClientFrame, TuiCompletionEdit, @@ -491,6 +492,8 @@ export type HostRequests = { response: { ok: boolean; path?: string; error?: string }; }; + repoHostContext: { params: { projectDir: string }; response: { context: RepoHostContext | null } }; + /** What the composer's `/`, `$` and `@` menus offer. Read on demand, never cached in main. */ listCommands: { params: { projectDir: string }; response: { commands: CommandInfo[] } }; listSkills: { params: { projectDir: string }; response: { skills: SkillInfo[] } };