From 463e9ec4e360f62a4d221fbafbda12efd9193de0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 23 Jan 2026 16:08:58 +0200 Subject: [PATCH] Add GitHub integration for PRs, issues and AI PR description (#205) * feat: integrate GitHub OAuth device flow across runtimes Add GitHub OAuth device flow endpoints across runtimes Introduce GitHubSettings UI panel and sidebar entry Persist GitHub auth state in per-runtime storage * feat: add GitHub PR status and PR description generation Show PR status for the current branch in the Git view Generate a pull request description from the diff between base and head Expose prStatus, prCreate, and prMerge APIs in web and desktop clients * feat: add GitHub PR ready for review Add API to mark pull requests as ready for review Show a Ready button for draft PRs and reflect status in UI Handle token expiration and GraphQL errors when marking ready --- bun.lock | 39 +- docs/github-features-plan.md | 302 +++++ docs/github-usage-report.md | 166 +++ package.json | 1 + packages/desktop/src-tauri/Cargo.lock | 2 +- .../src-tauri/src/assistant_notifications.rs | 1 - .../desktop/src-tauri/src/commands/files.rs | 72 +- .../desktop/src-tauri/src/commands/git.rs | 141 +- .../desktop/src-tauri/src/commands/github.rs | 1143 +++++++++++++++++ .../desktop/src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/commands/permissions.rs | 5 +- .../src-tauri/src/commands/settings.rs | 31 +- packages/desktop/src-tauri/src/main.rs | 69 +- .../desktop/src-tauri/src/opencode_config.rs | 46 +- .../desktop/src-tauri/src/skills_catalog.rs | 29 +- packages/desktop/src/api/git.ts | 12 + packages/desktop/src/api/github.ts | 62 + packages/desktop/src/api/index.ts | 2 + .../sections/openchamber/GitHubSettings.tsx | 332 +++++ .../sections/openchamber/OpenChamberPage.tsx | 8 + .../openchamber/OpenChamberSidebar.tsx | 7 +- packages/ui/src/components/views/GitView.tsx | 19 + .../components/views/git/CommitSection.tsx | 46 +- .../views/git/PullRequestSection.tsx | 440 +++++++ packages/ui/src/lib/api/types.ts | 116 ++ packages/ui/src/lib/gitApi.ts | 11 + packages/ui/src/lib/gitApiHttp.ts | 29 + packages/vscode/src/bridge.ts | 379 ++++++ packages/vscode/src/gitService.ts | 42 + packages/vscode/src/githubAuth.ts | 173 +++ packages/vscode/src/githubPr.ts | 338 +++++ packages/vscode/webview/api/git.ts | 12 + packages/vscode/webview/api/github.ts | 34 + packages/vscode/webview/api/index.ts | 2 + packages/web/server/index.js | 568 ++++++++ packages/web/server/lib/git-service.js | 34 + packages/web/server/lib/github-auth.js | 149 +++ packages/web/server/lib/github-device-flow.js | 50 + packages/web/server/lib/github-octokit.js | 10 + packages/web/server/lib/github-repo.js | 55 + packages/web/src/api/git.ts | 1 + packages/web/src/api/github.ts | 124 ++ packages/web/src/api/index.ts | 2 + 43 files changed, 4999 insertions(+), 106 deletions(-) create mode 100644 docs/github-features-plan.md create mode 100644 docs/github-usage-report.md create mode 100644 packages/desktop/src-tauri/src/commands/github.rs create mode 100644 packages/desktop/src/api/github.ts create mode 100644 packages/ui/src/components/sections/openchamber/GitHubSettings.tsx create mode 100644 packages/ui/src/components/views/git/PullRequestSection.tsx create mode 100644 packages/vscode/src/githubAuth.ts create mode 100644 packages/vscode/src/githubPr.ts create mode 100644 packages/vscode/webview/api/github.ts create mode 100644 packages/web/server/lib/github-auth.js create mode 100644 packages/web/server/lib/github-device-flow.js create mode 100644 packages/web/server/lib/github-octokit.js create mode 100644 packages/web/server/lib/github-repo.js create mode 100644 packages/web/src/api/github.ts diff --git a/bun.lock b/bun.lock index 625bb185..24145fee 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "@heroui/theme": "^2.4.23", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", + "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.1.19", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -94,7 +95,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.5.3", + "version": "1.5.5", "dependencies": { "@openchamber/ui": "workspace:*", "@tauri-apps/plugin-notification": "^2.3.3", @@ -117,7 +118,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.5.3", + "version": "1.5.5", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -211,7 +212,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.5.3", + "version": "1.5.5", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.1.19", @@ -234,7 +235,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.5.3", + "version": "1.5.5", "bin": { "openchamber": "./bin/cli.js", }, @@ -828,6 +829,30 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.2", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], + + "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@openchamber/desktop": ["@openchamber/desktop@workspace:packages/desktop"], "@openchamber/ui": ["@openchamber/ui@workspace:packages/ui"], @@ -1382,6 +1407,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], @@ -1816,6 +1843,8 @@ "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -2998,6 +3027,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], diff --git a/docs/github-features-plan.md b/docs/github-features-plan.md new file mode 100644 index 00000000..53763fe9 --- /dev/null +++ b/docs/github-features-plan.md @@ -0,0 +1,302 @@ +# GitHub Features Plan (PRD-ish) + +Goal: implement GitHub-powered workflows (PR panel + start sessions from Issue/PR) on top of the existing GitHub auth foundation. + +Non-goals (for this phase) +- Rebuild auth/token storage (already implemented) +- Rebuild worktree lifecycle/cleanup (already implemented) +- Add hard context size caps/fallback logic (explicitly out of scope) + +## Existing Primitives (MUST reuse) + +These already exist; do not reinvent. + +GitHub auth + connected user +- UI: `packages/ui/src/components/sections/openchamber/GitHubSettings.tsx` +- Runtime API: `GitHubAPI` in `packages/ui/src/lib/api/types.ts` +- Web endpoints: `packages/web/server/index.js` (`/api/github/*`) +- Desktop Tauri commands: `packages/desktop/src-tauri/src/commands/github.rs` +- VS Code bridge + storage: `packages/vscode/src/bridge.ts`, `packages/vscode/src/githubAuth.ts` + +Projects and directories +- Projects store (one project == one repo path): `packages/ui/src/stores/useProjectsStore.ts` +- Active project selection is already used across the app; use it to scope all GitHub operations. + +Worktree sessions +- Worktree creation and session wiring: + - `packages/ui/src/lib/worktreeSessionCreator.ts` + - Reuse `createWorktreeSession()` and `createWorktreeSessionForBranch(projectDirectory, branchName)`. +- Worktree cleanup/delete behavior is already present in session deletion flow: + - `packages/ui/src/components/session/SessionDialogs.tsx` + +“Synthetic parts” / hidden context in chat +- SDK supports `TextPartInput.synthetic?: boolean`: + - `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts` +- UI filters synthetic parts out of rendering: + - `packages/ui/src/lib/messages/synthetic.ts` +- Existing “seed new session from assistant answer” uses a hidden preface part: + - `packages/ui/src/stores/useSessionStore.ts` (`createSessionFromAssistantMessage`) + - `packages/ui/src/lib/opencode/client.ts` (`sendMessage({ prefaceText })`) + +Git tab layout +- Git view where PR panel will be inserted: + - `packages/ui/src/components/views/GitView.tsx` +- Changes + Commit already exist; History is below. + +## Guiding Principles + +- One directory/project == one git repo. All GitHub actions are scoped to the active project. +- Do not show synthetic context to the user; only show the human prompt. +- If the user lacks permission for an operation (merge, create branch, etc), degrade to “Open in GitHub”. +- Prefer server-side Octokit for web runtime; for desktop/vscode, implement equivalent runtime commands/bridge handlers. +- Keep UI consistent with existing patterns (provider OAuth, worktree sessions, commit message generation). + +## Feature A: Git Tab PR Panel (Create / Status / Merge) + +### Intent +While working on a feature branch, show PR status and actions inside the Git tab, without leaving the app. + +### Placement +Insert a new section between Commit and History in `packages/ui/src/components/views/GitView.tsx`. + +### Visibility Rules +- Show only if: + - repo is detected (`isGitRepo === true`) + - branch is not the “base branch” + +Base branch source (reuse existing config): +- `activeProject.worktreeDefaults.baseBranch` from `packages/ui/src/stores/useProjectsStore.ts` +- fallback to `status.tracking` remote HEAD logic later if needed (not required for v1). + +### UI States +1) GitHub not connected +- Show CTA: “Connect GitHub” (link to Settings -> OpenChamber -> GitHub). + +2) Connected but repo not resolvable to GitHub +- Show “Open remote in browser” if remote URL exists. +- Show error text explaining remote must be GitHub. + +3) PR does not exist for current branch +- Show create form: + - base branch (default: baseBranch) + - title (default from branch name) + - draft toggle + - description textarea + - “Generate description” button (AI) + - “Create PR” button + +4) PR exists +- Show summary: + - state (draft/open/merged) + - PR number + title + - checks summary + - mergeability (if available) + - “Open in GitHub” +- If user has merge permission and PR is mergeable: + - merge method dropdown (merge/squash/rebase) + - “Merge” button +- If cannot merge: + - disable merge button + show “Open in GitHub” + +### AI “Generate description” +Mirror commit message generation approach. + +Inputs: +- base branch +- branch name +- git diff for base...HEAD +- optionally selected files + +Output: +- title suggestion (optional) +- body with sections: + - Summary + - Testing + - Notes + +Reuse the same LLM infra used by commit generation in `packages/ui/src/components/views/GitView.tsx` (search for existing generation call and parallel it). + +### Required GitHub API Calls +- Resolve repo from git remote URL (origin) +- Find PR by head branch +- Create PR +- Get PR details + checks +- Merge PR + +### Implementation Notes +- Web runtime should use server endpoints + Octokit (token stays server-side). +- Desktop/vscode should use their runtime handlers (similar to GitHub auth) to avoid exposing token. + +## Feature B: Start Session From GitHub Issue + +### Intent +Create a new session seeded with issue context, without polluting chat with large issue bodies/comments. + +### Entry Point +Session kebab menu in `packages/ui/src/components/session/SessionSidebar.tsx`. + +Add new item: +- “New session from GitHub issue…” + +### Modal UI +Issue picker modal: +- list issues for current repo (open by default) +- search by title/number +- direct input: + - full URL + - `#123` or `123` +- checkbox: “Create in worktree” + +### Worktree option +If enabled: +- create a worktree session (reuse `createWorktreeSessionForBranch`) +- branch naming convention: + - `issue--` (slug derived from title) +- base branch: + - `activeProject.worktreeDefaults.baseBranch` + +If disabled: +- create normal session in project root directory. + +### Session Bootstrap (message) +Send a single user message with: +1) Visible text part: concise prompt, e.g. + - “Review the issue, clarify requirements, propose plan, then implement.” +2) Hidden synthetic parts: issue payload + - issue title/body + - labels, assignees, author + - comments (ordered) + - metadata (repo, number, url) + +This must use SDK-supported `TextPartInput.synthetic = true` so it is not rendered. +Do not invent a new hidden-context mechanism. + +### Required GitHub API Calls +- List issues +- Get issue by number +- List issue comments + +## Feature C: Start Session From GitHub PR (with worktree checkout) + +### Intent +Create a session seeded with PR context, with optional worktree checkout of PR branch (including forks). + +### Entry Point +Session kebab menu in `packages/ui/src/components/session/SessionSidebar.tsx`. + +Add new item: +- “New session from GitHub PR…” + +### Modal UI +PR picker modal: +- list open PRs +- search by title/number +- direct input: + - full URL + - `#123` or `123` +- checkbox: “Create session in PR worktree” + +### Worktree behavior +If enabled: +- if PR is from same repo: + - fetch head branch + - create worktree from that branch (reuse `createWorktreeSessionForBranch`) +- if PR is from fork: + - add temporary remote (derived from PR head repo clone URL) + - fetch `` + - create worktree + +Fallbacks: +- if fetch/remote fails or permission denied: + - still create a normal session with PR context + - show toast with “Open in GitHub” action + +### Session Bootstrap (message) +Same synthetic-parts approach as Issues. + +Hidden parts should include: +- PR title/body +- PR comments + review comments +- changed files list +- optionally full diff (explicitly no caps) +- checks/status summary + +Visible prompt text should instruct: +- review PR intent +- identify risks +- propose changes +- optionally implement changes in checked-out branch/worktree + +### Required GitHub API Calls +- List PRs +- Get PR +- List issue comments for PR +- List review comments +- List files +- Get checks/status + +## Cross-cutting: “Synthetic Parts” Sending API + +Current behavior: +- `opencodeClient.sendMessage()` supports `prefaceText` which becomes a separate `TextPartInput`. +- There is no first-class way (yet) to mark arbitrary parts as `synthetic: true` from callsites. + +Required change (shared for Features B/C): +- Extend `opencodeClient.sendMessage()` (in `packages/ui/src/lib/opencode/client.ts`) to support synthetic parts. + +Recommended minimal API change: +- allow `prefaceTextSynthetic?: boolean` (default true when used for hidden context) +- allow `additionalParts?: Array<{ text: string; synthetic?: boolean; files?: ... }>` +- ensure generated `TextPartInput` includes `synthetic` when requested + +This should reuse the existing filtering/rendering logic (no new UI hacks). + +## Cross-cutting: Repo Resolution + +Need a single helper to map current project repo -> GitHub owner/repo. + +Inputs: +- project directory root +- git remote URL (origin) + +Behavior: +- support common GitHub URL formats: + - `git@github.com:OWNER/REPO.git` + - `https://github.com/OWNER/REPO.git` + - `https://github.com/OWNER/REPO` + +Output: +- `{ owner, repo }` or null + +Use this for all GitHub feature endpoints. + +## Cross-cutting: Permission / Fallback Rules + +- Merge button enabled only if merge endpoint succeeds or mergeability indicates allowed. +- If not allowed: + - show “Open in GitHub” as primary action +- For PR worktrees from forks: + - if remote add/fetch fails => create normal session + “Open in GitHub” + +## Work Breakdown (Suggested Order) + +Phase 1: Shared plumbing +1) Repo resolution helper (remote URL -> owner/repo) +2) New message sending helper supporting `synthetic: true` parts +3) GitHub endpoints/commands for issue + PR fetch (read-only) + +Phase 2: Session bootstrap flows +4) Issue picker modal + session bootstrap +5) PR picker modal + session bootstrap +6) PR worktree checkout (fork support) + +Phase 3: Git tab PR panel +7) PR detect/status in Git tab +8) Create PR from branch +9) AI generate PR description +10) Merge (with fallback) + +## Open Questions (for later) + +- PR description generator prompt format: do we want the exact same “highlights” UI as commit gen, or a single-shot body generation? +- Worktree naming collision strategy for PR-based worktrees (owner/ref collisions) beyond current `sanitizeWorktreeSlug`. diff --git a/docs/github-usage-report.md b/docs/github-usage-report.md new file mode 100644 index 00000000..c88262ac --- /dev/null +++ b/docs/github-usage-report.md @@ -0,0 +1,166 @@ +# GitHub Integration (Auth Foundation) + +This repo now has a GitHub auth foundation intended to be reused by all future GitHub features (PRs/issues/worktrees/etc). + +It provides: +- GitHub OAuth Device Flow connect UX +- persistent token storage per runtime +- a small runtime API surface for UI +- server-side Octokit usage (web runtime) + +## Scopes + +Default scopes requested: + +``` +repo read:org workflow read:user user:email +``` + +Notes: +- Email is fetched from `/user` when available, otherwise `/user/emails` (requires `user:email`). +- Actions performed via this OAuth token are performed “as the user” (not a bot), but the OAuth App is visible under GitHub “Authorized OAuth Apps”. + +## UI + +Settings entry: +- `packages/ui/src/components/sections/openchamber/GitHubSettings.tsx` + +Behavior: +- shows connected user card (avatar + name/email/login) +- Connect triggers Device Flow and polls until authorized +- Disconnect clears the stored token + +## Runtime API (UI) + +`RuntimeAPIs.github` is optional (some environments may not expose it). + +Types: +- `packages/ui/src/lib/api/types.ts` (`GitHubAPI`, `GitHubAuthStatus`, `GitHubDeviceFlowStart`, `GitHubDeviceFlowComplete`) + +Methods: +- `authStatus(): { connected, user?, scope? }` +- `authStart(): { deviceCode, userCode, verificationUri, verificationUriComplete?, expiresIn, interval, scope? }` +- `authComplete(deviceCode): { connected: true, user, scope? } | { connected: false, status?, error? }` +- `authDisconnect(): { removed: boolean }` +- `me?(): user` (optional, mostly for debugging) + +Implementations: +- Web: `packages/web/src/api/github.ts` +- Desktop: `packages/desktop/src/api/github.ts` (calls Tauri commands) +- VS Code: `packages/vscode/webview/api/github.ts` (bridge messages) + +## Web Runtime (Express server) + +Endpoints (JSON): + +- `GET /api/github/auth/status` + - returns `{ connected: false }` or `{ connected: true, user, scope }` + +- `POST /api/github/auth/start` + - returns device flow payload: + - `{ deviceCode, userCode, verificationUri, verificationUriComplete?, expiresIn, interval, scope }` + +- `POST /api/github/auth/complete` + - request: `{ deviceCode }` + - returns either pending or success: + - pending: `{ connected: false, status, error }` + - success: `{ connected: true, user, scope }` + +- `DELETE /api/github/auth` + - clears stored token + - returns `{ success: true, removed: boolean }` + +- `GET /api/github/me` + - returns the authenticated user summary + +Code: +- endpoints: `packages/web/server/index.js` +- token store + config defaults: `packages/web/server/lib/github-auth.js` +- Octokit factory: `packages/web/server/lib/github-octokit.js` +- device flow helpers: `packages/web/server/lib/github-device-flow.js` + +## Desktop Runtime (Tauri) + +Tauri commands: +- `github_auth_status` +- `github_auth_start` +- `github_auth_complete` (param: `deviceCode`) +- `github_auth_disconnect` +- `github_me` + +Code: +- `packages/desktop/src-tauri/src/commands/github.rs` +- wired in invoke handler: `packages/desktop/src-tauri/src/main.rs` + +## VS Code Runtime + +Bridge message types handled in extension: +- `api:github/auth:status` +- `api:github/auth:start` +- `api:github/auth:complete` +- `api:github/auth:disconnect` +- `api:github/me` + +Code: +- storage + device flow + `/user` fetch: `packages/vscode/src/githubAuth.ts` +- bridge handlers: `packages/vscode/src/bridge.ts` + +## Token Storage + +- Web/server runtime: `~/.config/openchamber/github-auth.json` + - file mode `0600` best-effort + +- Desktop runtime: `~/.config/openchamber/github-auth.json` + - file mode `0600` best-effort + +- VS Code runtime: `${extensionGlobalStorage}/github-auth.json` + - file mode `0600` best-effort + +Stored fields (current shape; can evolve): + +```json +{ + "accessToken": "…", + "scope": "…", + "tokenType": "bearer", + "createdAt": 1730000000000, + "user": { + "login": "…", + "id": 123, + "avatarUrl": "…", + "name": "…", + "email": "…" + } +} +``` + +## Official OAuth App + +Default OAuth client id is baked in: +- `Ov23liNd8TxDcMXtAHHM` + +Overrides: +- Web/server: `OPENCHAMBER_GITHUB_CLIENT_ID` (env) +- Scopes override (web/server): `OPENCHAMBER_GITHUB_SCOPES` (env) + +Note: UI editing of client id/scopes was intentionally removed to reduce user confusion. + +## How to Use in New Features + +Preferred pattern: +- UI triggers new feature flows. +- Backend (web server or desktop/vscode runtime command/bridge) performs GitHub API calls using the stored token. +- Do not expose the token to the UI. + +Web/server feature endpoints should: +- require `{ connected: true }` state (return 401 if not connected) +- use Octokit with `auth` set to stored token +- accept repo/issue/pr identifiers from UI and fetch needed context + +Future “context bootstrap” idea: +- Add endpoints that take `{ owner, repo, number }` and return: + - issue/PR body + - comments + - changed files (PR) + - diff/patch summary +Then UI can start a session with a prefilled prompt. diff --git a/package.json b/package.json index 81ab39c3..24ec0add 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@heroui/theme": "^2.4.23", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", + "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.1.19", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 4ef49912..17017fef 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2977,7 +2977,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.5.3" +version = "1.5.5" dependencies = [ "anyhow", "axum", diff --git a/packages/desktop/src-tauri/src/assistant_notifications.rs b/packages/desktop/src-tauri/src/assistant_notifications.rs index 1e60c7fb..6b426baa 100644 --- a/packages/desktop/src-tauri/src/assistant_notifications.rs +++ b/packages/desktop/src-tauri/src/assistant_notifications.rs @@ -267,7 +267,6 @@ async fn handle_question_asked( properties: &Value, notified_questions: &Mutex>, ) { - let session_id = properties.get("sessionID").and_then(Value::as_str); let question_id = properties.get("id").and_then(Value::as_str); diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index 0c23d171..46425e0a 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -234,13 +234,11 @@ pub async fn list_directory( .output(); match output { - Ok(out) => { - String::from_utf8_lossy(&out.stdout) - .lines() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - } + Ok(out) => String::from_utf8_lossy(&out.stdout) + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), Err(_) => HashSet::new(), } }) @@ -507,9 +505,13 @@ pub async fn delete_path( } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref()) - .await - .map_err(|err| err.to_delete_message())?; + let resolved_path = resolve_sandboxed_path( + Some(trimmed.to_string()), + &workspace_roots, + default_root.as_ref(), + ) + .await + .map_err(|err| err.to_delete_message())?; let metadata = fs::metadata(&resolved_path) .await @@ -544,9 +546,13 @@ pub async fn rename_path( } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_old = resolve_sandboxed_path(Some(trimmed_old.to_string()), &workspace_roots, default_root.as_ref()) - .await - .map_err(|err| err.to_rename_message())?; + let resolved_old = resolve_sandboxed_path( + Some(trimmed_old.to_string()), + &workspace_roots, + default_root.as_ref(), + ) + .await + .map_err(|err| err.to_rename_message())?; let resolved_new = resolve_creatable_path(trimmed_new, &workspace_roots, default_root.as_ref()) .await .map_err(|err| err.to_rename_message())?; @@ -895,9 +901,13 @@ pub async fn read_file( } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref()) - .await - .map_err(|_| "File not found or access denied".to_string())?; + let resolved_path = resolve_sandboxed_path( + Some(trimmed.to_string()), + &workspace_roots, + default_root.as_ref(), + ) + .await + .map_err(|_| "File not found or access denied".to_string())?; let metadata = fs::metadata(&resolved_path) .await @@ -932,9 +942,13 @@ pub async fn read_file_binary( } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref()) - .await - .map_err(|_| "File not found or access denied".to_string())?; + let resolved_path = resolve_sandboxed_path( + Some(trimmed.to_string()), + &workspace_roots, + default_root.as_ref(), + ) + .await + .map_err(|_| "File not found or access denied".to_string())?; let metadata = fs::metadata(&resolved_path) .await @@ -1041,14 +1055,8 @@ fn build_shell_path_command(shell: &str) -> Vec { "-lic".to_string(), "source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(), ], - "fish" => vec![ - "-lic".to_string(), - "echo \"__PATH__=$PATH\"".to_string(), - ], - _ => vec![ - "-lic".to_string(), - "echo \"__PATH__=$PATH\"".to_string(), - ], + "fish" => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()], + _ => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()], } } @@ -1130,9 +1138,13 @@ pub async fn exec_commands( } let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; - let resolved_cwd = resolve_sandboxed_path(Some(cwd_trimmed.to_string()), &workspace_roots, default_root.as_ref()) - .await - .map_err(|_| "Working directory not found or access denied".to_string())?; + let resolved_cwd = resolve_sandboxed_path( + Some(cwd_trimmed.to_string()), + &workspace_roots, + default_root.as_ref(), + ) + .await + .map_err(|_| "Working directory not found or access denied".to_string())?; let metadata = fs::metadata(&resolved_cwd) .await diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs index 521c1ac7..5221bd52 100644 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ b/packages/desktop/src-tauri/src/commands/git.rs @@ -2042,7 +2042,9 @@ pub async fn get_remote_url( .map_err(|e| e.to_string())?; let remote_name = remote.unwrap_or_else(|| "origin".to_string()); - let url = run_git(&["remote", "get-url", &remote_name], &root).await.ok(); + let url = run_git(&["remote", "get-url", &remote_name], &root) + .await + .ok(); Ok(url.filter(|s| !s.is_empty())) } @@ -2162,7 +2164,11 @@ pub async fn set_git_identity( .await .map_err(|e| e.to_string())?; } - let _ = run_git(&["config", "--local", "--unset", "credential.helper"], &root).await; + let _ = run_git( + &["config", "--local", "--unset", "credential.helper"], + &root, + ) + .await; } else if auth_type == "token" && profile.host.is_some() { run_git(&["config", "--local", "credential.helper", "store"], &root) .await @@ -2330,3 +2336,134 @@ Diff summary: last_error.unwrap_or_else(|| "unknown error".to_string()) )) } + +#[tauri::command] +pub async fn generate_pr_description( + directory: String, + base: String, + head: String, + state: State<'_, DesktopRuntime>, +) -> Result { + let root = validate_git_path(&directory, state.settings()) + .await + .map_err(|e| e.to_string())?; + + if base.trim().is_empty() || head.trim().is_empty() { + return Err("base and head are required".to_string()); + } + + // 1. Collect PR range diffs (base...head) + let range = format!("{}...{}", base.trim(), head.trim()); + let files = { + let args = vec!["diff", "--name-only", range.as_str()]; + let raw = run_git(&args, &root).await.unwrap_or_default(); + raw.lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect::>() + }; + if files.is_empty() { + return Err("No diffs available for base...head".to_string()); + } + let mut diff_summaries = String::new(); + for file in files.iter() { + let context = "-U3"; + let args = vec![ + "diff", + "--no-color", + context, + range.as_str(), + "--", + file.as_str(), + ]; + if let Ok(diff) = run_git(&args, &root).await { + if !diff.trim().is_empty() { + diff_summaries.push_str(&format!("FILE: {}\n{}\n\n", file, diff)); + } + } + } + + if diff_summaries.is_empty() { + return Err("No diffs available for selected files".to_string()); + } + + // 2. Construct PR-specific prompt + let prompt = format!( + r#"You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {{\"title\": string, \"body\": string}} (ONLY JSON in response, no markdown fences) with these rules: +- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no \"feat:\", \"fix:\") +- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes +- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names +- Testing: bullet list (\"- Not tested\" allowed) +- Notes: bullet list; include breaking/rollout notes only when relevant +Context: +- base branch: {base} +- head branch: {head} + +Diff summary: +{diffs}"#, + base = base.trim(), + head = head.trim(), + diffs = diff_summaries + ); + + let model = "gpt-5-nano"; + + // 3. Call API + let client = Client::new(); + let res = client + .post("https://opencode.ai/zen/v1/responses") + .json(&serde_json::json!({ + "model": model, + "input": [{ "role": "user", "content": prompt }], + "max_output_tokens": 1200, + "stream": false, + "reasoning": { "effort": "low" } + })) + .send() + .await + .map_err(|e| e.to_string())?; + + if !res.status().is_success() { + return Err(format!("API request failed: {}", res.status())); + } + + let body_json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; + let raw_content = body_json["output"] + .as_array() + .and_then(|items| items.iter().find(|item| item["type"] == "message")) + .and_then(|item| item["content"].as_array()) + .and_then(|content| content.iter().find(|entry| entry["type"] == "output_text")) + .and_then(|entry| entry["text"].as_str()) + .unwrap_or("") + .trim(); + + if raw_content.is_empty() { + return Err("No PR description returned by generator".to_string()); + } + + let cleaned = raw_content + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + + let extracted = extract_json_object(cleaned); + let candidates = [ + Some(cleaned.to_string()), + extracted, + Some(raw_content.to_string()), + ]; + + for candidate in candidates.iter().flatten() { + if !(candidate.starts_with('{') || candidate.starts_with('[')) { + continue; + } + if let Ok(parsed) = serde_json::from_str::(candidate) { + let title = parsed.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let body = parsed.get("body").and_then(|v| v.as_str()).unwrap_or(""); + return Ok(serde_json::json!({ "title": title, "body": body })); + } + } + + Ok(serde_json::json!({ "title": "", "body": raw_content })) +} diff --git a/packages/desktop/src-tauri/src/commands/github.rs b/packages/desktop/src-tauri/src/commands/github.rs new file mode 100644 index 00000000..691660c7 --- /dev/null +++ b/packages/desktop/src-tauri/src/commands/github.rs @@ -0,0 +1,1143 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::PathBuf; +use tauri::State; +use tokio::fs; +use tokio::process::Command; + +use crate::DesktopRuntime; + +const DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; +const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; +const API_USER_URL: &str = "https://api.github.com/user"; +const API_EMAILS_URL: &str = "https://api.github.com/user/emails"; +const API_PULLS_URL_PREFIX: &str = "https://api.github.com/repos"; +const API_GRAPHQL_URL: &str = "https://api.github.com/graphql"; +const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +const DEFAULT_GITHUB_CLIENT_ID: &str = "Ov23liNd8TxDcMXtAHHM"; +const DEFAULT_GITHUB_SCOPES: &str = "repo read:org workflow read:user user:email"; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubRepoRef { + owner: String, + repo: String, + url: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubChecksSummary { + state: String, + total: u64, + success: u64, + failure: u64, + pending: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestSummary { + number: u64, + title: String, + url: String, + state: String, + draft: bool, + base: String, + head: String, + #[serde(skip_serializing_if = "Option::is_none")] + head_sha: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mergeable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mergeable_state: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestStatus { + connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + checks: Option, + #[serde(skip_serializing_if = "Option::is_none")] + can_merge: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestMergeResult { + merged: bool, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestReadyResult { + ready: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubUserSummary { + login: String, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubAuthStatus { + connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubDeviceFlowStart { + device_code: String, + user_code: String, + verification_uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + verification_uri_complete: Option, + expires_in: u64, + interval: u64, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubDeviceFlowCompleteSuccess { + connected: bool, + user: GitHubUserSummary, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubDeviceFlowCompletePending { + connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum GitHubDeviceFlowComplete { + Success(GitHubDeviceFlowCompleteSuccess), + Pending(GitHubDeviceFlowCompletePending), +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubDisconnectResult { + removed: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct StoredAuth { + access_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + token_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user: Option, +} + +#[derive(Debug, Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_uri: String, + #[serde(default)] + verification_uri_complete: Option, + expires_in: u64, + interval: u64, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + #[serde(default)] + access_token: Option, + #[serde(default)] + scope: Option, + #[serde(default)] + token_type: Option, + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiUserResponse { + login: String, + id: u64, + #[serde(default)] + avatar_url: Option, + #[serde(default)] + name: Option, + #[serde(default)] + email: Option, +} + +#[derive(Debug, Deserialize)] +struct PrListItem { + number: u64, +} + +#[derive(Debug, Deserialize)] +struct PullRef { + #[serde(rename = "ref")] + ref_name: String, + sha: String, +} + +#[derive(Debug, Deserialize)] +struct PullBaseRef { + #[serde(rename = "ref")] + ref_name: String, +} + +#[derive(Debug, Deserialize)] +struct PullDetailsResponse { + number: u64, + title: String, + html_url: String, + state: String, + #[serde(default)] + draft: bool, + #[serde(default)] + merged: bool, + #[serde(default)] + mergeable: Option, + #[serde(default)] + mergeable_state: Option, + head: PullRef, + base: PullBaseRef, + #[serde(default)] + node_id: Option, +} + +#[derive(Debug, Deserialize)] +struct CombinedStatusEntry { + state: String, +} + +#[derive(Debug, Deserialize)] +struct CombinedStatusResponse { + #[serde(default)] + statuses: Vec, +} + +#[derive(Debug, Deserialize)] +struct PermissionResponse { + permission: String, +} + +#[derive(Debug, Serialize)] +struct PullCreateRequest<'a> { + title: &'a str, + head: &'a str, + base: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + draft: Option, +} + +#[derive(Debug, Deserialize)] +struct PullCreateResponse { + number: u64, + title: String, + html_url: String, + state: String, + #[serde(default)] + draft: bool, + head: PullRef, + base: PullBaseRef, + #[serde(default)] + mergeable: Option, + #[serde(default)] + mergeable_state: Option, +} + +#[derive(Debug, Serialize)] +struct PullMergeRequest<'a> { + merge_method: &'a str, +} + +#[derive(Debug, Deserialize)] +struct PullMergeResponse { + merged: bool, + #[serde(default)] + message: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiEmailEntry { + email: String, + #[serde(default)] + primary: bool, + #[serde(default)] + verified: bool, +} + +fn github_auth_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| "No home directory".to_string())?; + let mut dir = home; + dir.push(".config"); + dir.push("openchamber"); + dir.push("github-auth.json"); + Ok(dir) +} + +async fn read_auth_file() -> Option { + let path = github_auth_path().ok()?; + let bytes = fs::read(&path).await.ok()?; + serde_json::from_slice::(&bytes).ok() +} + +async fn write_auth_file(auth: &StoredAuth) -> Result<(), String> { + let path = github_auth_path()?; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent).await; + } + let bytes = serde_json::to_vec_pretty(auth).map_err(|e| e.to_string())?; + fs::write(&path, bytes).await.map_err(|e| e.to_string())?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(metadata) = std::fs::metadata(&path) { + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + let _ = std::fs::set_permissions(&path, perms); + } + } + + Ok(()) +} + +async fn clear_auth_file() -> bool { + let path = match github_auth_path() { + Ok(p) => p, + Err(_) => return false, + }; + match fs::remove_file(&path).await { + Ok(_) => true, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, + Err(_) => false, + } +} + +fn read_string_setting(settings: &Value, key: &str) -> Option { + settings + .get(key)? + .as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +async fn resolve_client_config(state: &DesktopRuntime) -> (String, String) { + let settings = state + .settings() + .load() + .await + .unwrap_or(Value::Object(Default::default())); + let client_id = read_string_setting(&settings, "githubClientId") + .unwrap_or_else(|| DEFAULT_GITHUB_CLIENT_ID.to_string()); + let scopes = read_string_setting(&settings, "githubScopes") + .unwrap_or_else(|| DEFAULT_GITHUB_SCOPES.to_string()); + (client_id, scopes) +} + +async fn fetch_primary_email(access_token: &str) -> Result, String> { + let client = reqwest::Client::new(); + let resp = client + .get(API_EMAILS_URL) + .header("Accept", "application/vnd.github+json") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "OpenChamber") + .send() + .await + .map_err(|e| e.to_string())?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("unauthorized".to_string()); + } + + if !resp.status().is_success() { + return Ok(None); + } + + let list = resp + .json::>() + .await + .map_err(|e| e.to_string())?; + + let primary_verified = list + .iter() + .find(|e| e.primary && e.verified) + .map(|e| e.email.clone()); + if primary_verified.is_some() { + return Ok(primary_verified); + } + + let any_verified = list.iter().find(|e| e.verified).map(|e| e.email.clone()); + Ok(any_verified) +} + +async fn get_origin_remote_url(directory: &str) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(directory) + .arg("remote") + .arg("get-url") + .arg("origin") + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn parse_github_remote_url(remote_url: &str) -> Option { + let trimmed = remote_url.trim(); + if trimmed.is_empty() { + return None; + } + + if let Some(rest) = trimmed.strip_prefix("git@github.com:") { + let cleaned = rest.trim_end_matches(".git"); + let (owner, repo) = cleaned.split_once('/')?; + if owner.is_empty() || repo.is_empty() { + return None; + } + return Some(GitHubRepoRef { + owner: owner.to_string(), + repo: repo.to_string(), + url: format!("https://github.com/{}/{}", owner, repo), + }); + } + + if let Some(rest) = trimmed.strip_prefix("ssh://git@github.com/") { + let cleaned = rest.trim_end_matches(".git"); + let (owner, repo) = cleaned.split_once('/')?; + if owner.is_empty() || repo.is_empty() { + return None; + } + return Some(GitHubRepoRef { + owner: owner.to_string(), + repo: repo.to_string(), + url: format!("https://github.com/{}/{}", owner, repo), + }); + } + + if let Ok(url) = url::Url::parse(trimmed) { + if url.host_str() != Some("github.com") { + return None; + } + let path = url.path().trim_matches('/').trim_end_matches(".git"); + let (owner, repo) = path.split_once('/')?; + if owner.is_empty() || repo.is_empty() { + return None; + } + return Some(GitHubRepoRef { + owner: owner.to_string(), + repo: repo.to_string(), + url: format!("https://github.com/{}/{}", owner, repo), + }); + } + + None +} + +async fn resolve_repo_from_directory(directory: &str) -> Option { + let remote = get_origin_remote_url(directory).await?; + parse_github_remote_url(&remote) +} + +async fn github_get_json Deserialize<'de>>( + url: &str, + access_token: &str, +) -> Result { + let client = reqwest::Client::new(); + let resp = client + .get(url) + .header("Accept", "application/vnd.github+json") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "OpenChamber") + .send() + .await + .map_err(|e| e.to_string())?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("unauthorized".to_string()); + } + if !resp.status().is_success() { + return Err(format!("GitHub request failed: {}", resp.status())); + } + resp.json::().await.map_err(|e| e.to_string()) +} + +async fn github_post_json Deserialize<'de>, B: Serialize>( + url: &str, + access_token: &str, + body: &B, +) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post(url) + .header("Accept", "application/vnd.github+json") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "OpenChamber") + .json(body) + .send() + .await + .map_err(|e| e.to_string())?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("unauthorized".to_string()); + } + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(format!("GitHub request failed: {} {}", status, text)); + } + resp.json::().await.map_err(|e| e.to_string()) +} + + +async fn fetch_me(access_token: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .get(API_USER_URL) + .header("Accept", "application/vnd.github+json") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "OpenChamber") + .send() + .await + .map_err(|e| e.to_string())?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("unauthorized".to_string()); + } + + if !resp.status().is_success() { + return Err(format!("GitHub /user failed: {}", resp.status())); + } + + let payload = resp + .json::() + .await + .map_err(|e| e.to_string())?; + + let email = match payload.email.clone() { + Some(v) if !v.trim().is_empty() => Some(v), + _ => fetch_primary_email(access_token).await.ok().flatten(), + }; + + Ok(GitHubUserSummary { + login: payload.login, + id: Some(payload.id), + avatar_url: payload.avatar_url, + name: payload.name, + email, + }) +} + +#[tauri::command] +pub async fn github_auth_status( + _state: State<'_, DesktopRuntime>, +) -> Result { + let stored = read_auth_file().await; + let Some(stored) = stored else { + return Ok(GitHubAuthStatus { + connected: false, + user: None, + scope: None, + }); + }; + + if stored.access_token.trim().is_empty() { + let _ = clear_auth_file().await; + return Ok(GitHubAuthStatus { + connected: false, + user: None, + scope: None, + }); + } + + match fetch_me(&stored.access_token).await { + Ok(user) => Ok(GitHubAuthStatus { + connected: true, + user: Some(user), + scope: stored.scope, + }), + Err(err) if err == "unauthorized" => { + let _ = clear_auth_file().await; + Ok(GitHubAuthStatus { + connected: false, + user: None, + scope: None, + }) + } + Err(err) => Err(err), + } +} + +#[tauri::command] +pub async fn github_auth_start( + state: State<'_, DesktopRuntime>, +) -> Result { + let (client_id, scopes) = resolve_client_config(state.inner()).await; + + let client = reqwest::Client::new(); + let resp = client + .post(DEVICE_CODE_URL) + .header("Accept", "application/json") + .header("User-Agent", "OpenChamber") + .form(&[ + ("client_id", client_id.as_str()), + ("scope", scopes.as_str()), + ]) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + return Err(format!("GitHub device code failed: {}", resp.status())); + } + + let payload = resp + .json::() + .await + .map_err(|e| e.to_string())?; + Ok(GitHubDeviceFlowStart { + device_code: payload.device_code, + user_code: payload.user_code, + verification_uri: payload.verification_uri, + verification_uri_complete: payload.verification_uri_complete, + expires_in: payload.expires_in, + interval: payload.interval, + scope: Some(scopes), + }) +} + +#[tauri::command] +pub async fn github_auth_complete( + #[allow(non_snake_case)] + deviceCode: String, + state: State<'_, DesktopRuntime>, +) -> Result { + let device_code = deviceCode; + if device_code.trim().is_empty() { + return Err("deviceCode is required".to_string()); + } + + let (client_id, _) = resolve_client_config(state.inner()).await; + + let client = reqwest::Client::new(); + let resp = client + .post(ACCESS_TOKEN_URL) + .header("Accept", "application/json") + .header("User-Agent", "OpenChamber") + .form(&[ + ("client_id", client_id.as_str()), + ("device_code", device_code.as_str()), + ("grant_type", DEVICE_GRANT_TYPE), + ]) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + return Err(format!("GitHub token exchange failed: {}", resp.status())); + } + + let payload = resp + .json::() + .await + .map_err(|e| e.to_string())?; + if let Some(error) = payload.error.clone() { + return Ok(GitHubDeviceFlowComplete::Pending( + GitHubDeviceFlowCompletePending { + connected: false, + status: Some(error.clone()), + error: Some(payload.error_description.unwrap_or(error)), + }, + )); + } + + let access_token = payload.access_token.unwrap_or_default(); + if access_token.trim().is_empty() { + return Err("Missing access_token from GitHub".to_string()); + } + + let user = fetch_me(&access_token).await.map_err(|e| { + if e == "unauthorized" { + "GitHub token invalid".to_string() + } else { + e + } + })?; + + let stored = StoredAuth { + access_token: access_token.clone(), + scope: payload.scope.clone(), + token_type: payload.token_type.clone(), + created_at: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + ), + user: Some(user.clone()), + }; + write_auth_file(&stored).await?; + + Ok(GitHubDeviceFlowComplete::Success( + GitHubDeviceFlowCompleteSuccess { + connected: true, + user, + scope: payload.scope, + }, + )) +} + +#[tauri::command] +pub async fn github_auth_disconnect( + _state: State<'_, DesktopRuntime>, +) -> Result { + let removed = clear_auth_file().await; + Ok(GitHubDisconnectResult { removed }) +} + +#[tauri::command] +pub async fn github_me(_state: State<'_, DesktopRuntime>) -> Result { + let stored = read_auth_file().await; + let Some(stored) = stored else { + return Err("GitHub not connected".to_string()); + }; + match fetch_me(&stored.access_token).await { + Ok(user) => Ok(user), + Err(err) if err == "unauthorized" => { + let _ = clear_auth_file().await; + Err("GitHub token expired or revoked".to_string()) + } + Err(err) => Err(err), + } +} + +#[tauri::command] +pub async fn github_pr_status( + directory: String, + branch: String, + _state: State<'_, DesktopRuntime>, +) -> Result { + let directory = directory.trim().to_string(); + let branch = branch.trim().to_string(); + if directory.is_empty() || branch.is_empty() { + return Err("directory and branch are required".to_string()); + } + + let stored = read_auth_file().await; + let Some(stored) = stored else { + return Ok(GitHubPullRequestStatus { + connected: false, + repo: None, + branch: Some(branch), + pr: None, + checks: None, + can_merge: None, + }); + }; + + if stored.access_token.trim().is_empty() { + let _ = clear_auth_file().await; + return Ok(GitHubPullRequestStatus { + connected: false, + repo: None, + branch: Some(branch), + pr: None, + checks: None, + can_merge: None, + }); + } + + let repo = resolve_repo_from_directory(&directory).await; + let Some(repo) = repo else { + return Ok(GitHubPullRequestStatus { + connected: true, + repo: None, + branch: Some(branch), + pr: None, + checks: None, + can_merge: Some(false), + }); + }; + + let head = format!("{}:{}", repo.owner, branch); + let head_encoded = urlencoding::encode(&head); + let list_url = format!( + "{}/{}/{}/pulls?state=open&head={}&per_page=10", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, head_encoded + ); + + let list = github_get_json::>(&list_url, &stored.access_token).await; + let list = match list { + Ok(v) => v, + Err(err) if err == "unauthorized" => { + let _ = clear_auth_file().await; + return Ok(GitHubPullRequestStatus { + connected: false, + repo: None, + branch: Some(branch), + pr: None, + checks: None, + can_merge: None, + }); + } + Err(err) => return Err(err), + }; + + let Some(first) = list.first() else { + return Ok(GitHubPullRequestStatus { + connected: true, + repo: Some(repo), + branch: Some(branch), + pr: None, + checks: None, + can_merge: Some(false), + }); + }; + + let pr_url = format!( + "{}/{}/{}/pulls/{}", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, first.number + ); + let pr = github_get_json::(&pr_url, &stored.access_token).await?; + + // Checks summary + let mut checks: Option = None; + let status_url = format!( + "{}/{}/{}/commits/{}/status", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, pr.head.sha + ); + if let Ok(status) = github_get_json::(&status_url, &stored.access_token).await { + let mut success = 0; + let mut failure = 0; + let mut pending = 0; + for s in status.statuses.iter() { + match s.state.as_str() { + "success" => success += 1, + "failure" | "error" => failure += 1, + "pending" => pending += 1, + _ => {} + } + } + let total = success + failure + pending; + let state = if failure > 0 { + "failure" + } else if pending > 0 { + "pending" + } else if total > 0 { + "success" + } else { + "unknown" + }; + checks = Some(GitHubChecksSummary { + state: state.to_string(), + total, + success, + failure, + pending, + }); + } + + // Permissions (best-effort) + let mut can_merge = None; + if let Some(user) = stored.user.as_ref() { + if !user.login.is_empty() { + let perm_url = format!( + "{}/{}/{}/collaborators/{}/permission", + API_PULLS_URL_PREFIX, + repo.owner, + repo.repo, + urlencoding::encode(&user.login) + ); + if let Ok(perm) = github_get_json::(&perm_url, &stored.access_token).await { + let p = perm.permission; + can_merge = Some(p == "admin" || p == "maintain" || p == "write"); + } + } + } + + let state = if pr.merged { + "merged" + } else if pr.state == "closed" { + "closed" + } else { + "open" + }; + + Ok(GitHubPullRequestStatus { + connected: true, + repo: Some(repo), + branch: Some(branch), + pr: Some(GitHubPullRequestSummary { + number: pr.number, + title: pr.title, + url: pr.html_url, + state: state.to_string(), + draft: pr.draft, + base: pr.base.ref_name, + head: pr.head.ref_name, + head_sha: Some(pr.head.sha), + mergeable: pr.mergeable, + mergeable_state: pr.mergeable_state, + }), + checks, + can_merge, + }) +} + +#[tauri::command] +pub async fn github_pr_create( + directory: String, + title: String, + head: String, + base: String, + body: Option, + draft: Option, + _state: State<'_, DesktopRuntime>, +) -> Result { + let directory = directory.trim().to_string(); + let title = title.trim().to_string(); + let head = head.trim().to_string(); + let base = base.trim().to_string(); + if directory.is_empty() || title.is_empty() || head.is_empty() || base.is_empty() { + return Err("directory, title, head, base are required".to_string()); + } + + let stored = read_auth_file().await; + let Some(stored) = stored else { + return Err("GitHub not connected".to_string()); + }; + if stored.access_token.trim().is_empty() { + let _ = clear_auth_file().await; + return Err("GitHub not connected".to_string()); + } + + let repo = resolve_repo_from_directory(&directory) + .await + .ok_or_else(|| "Unable to resolve GitHub repo from git remote".to_string())?; + + let url = format!("{}/{}/{}/pulls", API_PULLS_URL_PREFIX, repo.owner, repo.repo); + let request = PullCreateRequest { + title: &title, + head: &head, + base: &base, + body: body.as_deref(), + draft, + }; + + let created = github_post_json::(&url, &stored.access_token, &request).await?; + + Ok(GitHubPullRequestSummary { + number: created.number, + title: created.title, + url: created.html_url, + state: if created.state == "closed" { + "closed".to_string() + } else { + "open".to_string() + }, + draft: created.draft, + base: created.base.ref_name, + head: created.head.ref_name, + head_sha: Some(created.head.sha), + mergeable: created.mergeable, + mergeable_state: created.mergeable_state, + }) +} + +#[tauri::command] +pub async fn github_pr_merge( + directory: String, + number: u64, + method: String, + _state: State<'_, DesktopRuntime>, +) -> Result { + let directory = directory.trim().to_string(); + let method = method.trim().to_string(); + if directory.is_empty() { + return Err("directory is required".to_string()); + } + if number == 0 { + return Err("number is required".to_string()); + } + + let stored = read_auth_file().await; + let Some(stored) = stored else { + return Err("GitHub not connected".to_string()); + }; + if stored.access_token.trim().is_empty() { + let _ = clear_auth_file().await; + return Err("GitHub not connected".to_string()); + } + + let repo = resolve_repo_from_directory(&directory) + .await + .ok_or_else(|| "Unable to resolve GitHub repo from git remote".to_string())?; + + let url = format!( + "{}/{}/{}/pulls/{}/merge", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, number + ); + let merge_method = if method.is_empty() { "merge" } else { method.as_str() }; + let request = PullMergeRequest { merge_method }; + + let client = reqwest::Client::new(); + let resp = client + .put(url) + .header("Accept", "application/vnd.github+json") + .header("Authorization", format!("Bearer {}", stored.access_token)) + .header("User-Agent", "OpenChamber") + .json(&request) + .send() + .await + .map_err(|e| e.to_string())?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let _ = clear_auth_file().await; + return Err("GitHub token expired or revoked".to_string()); + } + if resp.status() == reqwest::StatusCode::FORBIDDEN { + return Err("Not authorized to merge this PR".to_string()); + } + if resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED + || resp.status() == reqwest::StatusCode::CONFLICT + { + return Ok(GitHubPullRequestMergeResult { + merged: false, + message: Some("PR not mergeable".to_string()), + }); + } + if !resp.status().is_success() { + return Err(format!("GitHub merge failed: {}", resp.status())); + } + + let parsed = resp.json::().await.map_err(|e| e.to_string())?; + Ok(GitHubPullRequestMergeResult { + merged: parsed.merged, + message: parsed.message, + }) +} + +#[tauri::command] +pub async fn github_pr_ready( + directory: String, + number: u64, + _state: State<'_, DesktopRuntime>, +) -> Result { + let directory = directory.trim().to_string(); + if directory.is_empty() { + return Err("directory is required".to_string()); + } + if number == 0 { + return Err("number is required".to_string()); + } + + let stored = read_auth_file().await; + let Some(stored) = stored else { + return Err("GitHub not connected".to_string()); + }; + if stored.access_token.trim().is_empty() { + let _ = clear_auth_file().await; + return Err("GitHub not connected".to_string()); + } + + let repo = resolve_repo_from_directory(&directory) + .await + .ok_or_else(|| "Unable to resolve GitHub repo from git remote".to_string())?; + + let pr_url = format!( + "{}/{}/{}/pulls/{}", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, number + ); + let pr = github_get_json::(&pr_url, &stored.access_token).await?; + let node_id = pr + .node_id + .ok_or_else(|| "Failed to resolve PR node id".to_string())?; + + if !pr.draft { + return Ok(GitHubPullRequestReadyResult { ready: true }); + } + + let query = "mutation($pullRequestId: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { pullRequest { id isDraft } } }"; + let payload = serde_json::json!({ + "query": query, + "variables": { "pullRequestId": node_id } + }); + + let client = reqwest::Client::new(); + let resp = client + .post(API_GRAPHQL_URL) + .header("Accept", "application/vnd.github+json") + .header("Authorization", format!("Bearer {}", stored.access_token)) + .header("User-Agent", "OpenChamber") + .json(&payload) + .send() + .await + .map_err(|e| e.to_string())?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let _ = clear_auth_file().await; + return Err("GitHub token expired or revoked".to_string()); + } + if resp.status() == reqwest::StatusCode::FORBIDDEN { + return Err("Not authorized to mark PR ready".to_string()); + } + if !resp.status().is_success() { + return Err(format!("GitHub request failed: {}", resp.status())); + } + + let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + if body.get("errors").is_some() { + return Err("GitHub GraphQL error".to_string()); + } + + Ok(GitHubPullRequestReadyResult { ready: true }) +} diff --git a/packages/desktop/src-tauri/src/commands/mod.rs b/packages/desktop/src-tauri/src/commands/mod.rs index 3f612e9d..e37bceef 100644 --- a/packages/desktop/src-tauri/src/commands/mod.rs +++ b/packages/desktop/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod files; pub mod git; +pub mod github; pub mod logs; pub mod notifications; pub mod permissions; diff --git a/packages/desktop/src-tauri/src/commands/permissions.rs b/packages/desktop/src-tauri/src/commands/permissions.rs index 85038f50..fdefef18 100644 --- a/packages/desktop/src-tauri/src/commands/permissions.rs +++ b/packages/desktop/src-tauri/src/commands/permissions.rs @@ -129,7 +129,10 @@ pub async fn process_directory_selection( if let Some(obj) = settings.as_object_mut() { obj.insert("activeProjectId".to_string(), json!(project_id.clone())); - obj.insert("lastDirectory".to_string(), json!(normalized_path_for_update)); + obj.insert( + "lastDirectory".to_string(), + json!(normalized_path_for_update), + ); } (settings, project_id) diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index 1947d83a..69ffec5f 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use chrono::Utc; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashSet; use tauri::State; @@ -233,6 +233,20 @@ fn sanitize_settings_update(payload: &Value) -> Value { result_obj.insert("markdownDisplayMode".to_string(), json!(s)); } } + + // GitHub OAuth config (non-secret) + if let Some(Value::String(s)) = obj.get("githubClientId") { + let trimmed = s.trim(); + if !trimmed.is_empty() { + result_obj.insert("githubClientId".to_string(), json!(trimmed)); + } + } + if let Some(Value::String(s)) = obj.get("githubScopes") { + let trimmed = s.trim(); + if !trimmed.is_empty() { + result_obj.insert("githubScopes".to_string(), json!(trimmed)); + } + } if let Some(Value::String(s)) = obj.get("defaultModel") { let trimmed = s.trim(); if trimmed.is_empty() { @@ -304,7 +318,10 @@ fn sanitize_settings_update(payload: &Value) -> Value { if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") { let parsed = n .as_u64() - .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) + .or_else(|| { + n.as_i64() + .and_then(|v| if v >= 0 { Some(v as u64) } else { None }) + }) .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); if let Some(value) = parsed { let clamped = value.max(10).min(500); @@ -314,7 +331,10 @@ fn sanitize_settings_update(payload: &Value) -> Value { if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") { let parsed = n .as_u64() - .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) + .or_else(|| { + n.as_i64() + .and_then(|v| if v >= 0 { Some(v as u64) } else { None }) + }) .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); if let Some(value) = parsed { let clamped = value.max(20).min(500); @@ -324,7 +344,10 @@ fn sanitize_settings_update(payload: &Value) -> Value { if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") { let parsed = n .as_u64() - .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) + .or_else(|| { + n.as_i64() + .and_then(|v| if v >= 0 { Some(v as u64) } else { None }) + }) .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); if let Some(value) = parsed { let clamped = value.max(30).min(1000); diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index a0c98366..875f9cea 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -28,18 +28,26 @@ use axum::{ routing::{any, get, post}, Json, Router, }; -use commands::files::{create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary, rename_path, search_files, write_file}; +use commands::files::{ + create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary, + rename_path, search_files, write_file, +}; use commands::git::{ - add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch, + add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch, - discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, get_commit_files, - get_current_git_identity, has_local_identity, get_git_branches, get_git_diff, get_git_file_diff, + discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, + get_commit_files, get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff, get_git_identities, get_git_log, get_git_status, get_global_git_identity, get_remote_url, - git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree, - revert_git_file, set_git_identity, update_git_identity, + git_fetch, git_pull, git_push, has_local_identity, is_linked_worktree, list_git_worktrees, + remove_git_worktree, rename_branch, revert_git_file, set_git_identity, update_git_identity, + generate_pr_description, }; use commands::logs::fetch_desktop_logs; +use commands::github::{ + github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me, + github_pr_create, github_pr_merge, github_pr_ready, github_pr_status, +}; use commands::notifications::desktop_notify; use commands::permissions::{ pick_directory, process_directory_selection, request_directory_access, @@ -880,6 +888,7 @@ fn main() { set_git_identity, discover_git_credentials, generate_commit_message, + generate_pr_description, create_terminal_session, send_terminal_input, resize_terminal, @@ -888,6 +897,15 @@ fn main() { force_kill_terminal, fetch_desktop_logs, desktop_notify, + github_auth_status, + github_auth_start, + github_auth_complete, + github_auth_disconnect, + github_me, + github_pr_status, + github_pr_create, + github_pr_merge, + github_pr_ready, ]) .on_menu_event(|app, event| { #[cfg(target_os = "macos")] @@ -2256,7 +2274,9 @@ async fn handle_config_routes( resolve_project_directory(&state, None).await.ok() }; - match opencode_config::get_provider_sources(trimmed, working_directory.as_deref()).await { + match opencode_config::get_provider_sources(trimmed, working_directory.as_deref()) + .await + { Ok(mut sources) => { let auth = opencode_auth::get_provider_auth(trimmed).await; sources.auth.exists = auth.ok().flatten().is_some(); @@ -2324,13 +2344,30 @@ async fn handle_config_routes( let removal_result = if scope == "auth" { opencode_auth::remove_provider_auth(trimmed).await } else if scope == "user" { - opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::User).await + opencode_config::remove_provider_config( + trimmed, + working_directory.as_deref(), + opencode_config::ProviderScope::User, + ) + .await } else if scope == "project" { - opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Project).await + opencode_config::remove_provider_config( + trimmed, + working_directory.as_deref(), + opencode_config::ProviderScope::Project, + ) + .await } else if scope == "custom" { - opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Custom).await + opencode_config::remove_provider_config( + trimmed, + working_directory.as_deref(), + opencode_config::ProviderScope::Custom, + ) + .await } else if scope == "all" { - let auth_removed = opencode_auth::remove_provider_auth(trimmed).await.unwrap_or(false); + let auth_removed = opencode_auth::remove_provider_auth(trimmed) + .await + .unwrap_or(false); let user_removed = opencode_config::remove_provider_config( trimmed, working_directory.as_deref(), @@ -2500,7 +2537,10 @@ async fn change_directory_handler( "activeProjectId".to_string(), Value::String(active_project_id.clone()), ); - map.insert("lastDirectory".to_string(), Value::String(path_value.clone())); + map.insert( + "lastDirectory".to_string(), + Value::String(path_value.clone()), + ); settings }) @@ -2645,7 +2685,6 @@ impl SettingsStore { } } - pub(crate) async fn update_with(&self, f: F) -> Result<(Value, R)> where F: FnOnce(Value) -> (Value, R), @@ -2653,7 +2692,9 @@ impl SettingsStore { let _lock = self.guard.lock().await; let current = match fs::read(&self.path).await { - Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())), + Ok(bytes) => { + serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())) + } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Value::Object(Default::default()) } diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs index 6c3a7f78..57111b53 100644 --- a/packages/desktop/src-tauri/src/opencode_config.rs +++ b/packages/desktop/src-tauri/src/opencode_config.rs @@ -143,7 +143,10 @@ fn get_project_config_file(working_directory: &Path) -> PathBuf { } // Default to root opencode.json for new configs - candidates.into_iter().next().unwrap_or_else(|| working_directory.join("opencode.json")) + candidates + .into_iter() + .next() + .unwrap_or_else(|| working_directory.join("opencode.json")) } /// Get custom config file path from OPENCODE_CONFIG env var @@ -390,15 +393,29 @@ pub async fn get_provider_sources( .is_some(); Ok(ProviderSources { - auth: ProviderSourceInfo { exists: false, path: None }, - user: ProviderSourceInfo { exists: user_exists, path: Some(layers.paths.user.to_string_lossy().to_string()) }, + auth: ProviderSourceInfo { + exists: false, + path: None, + }, + user: ProviderSourceInfo { + exists: user_exists, + path: Some(layers.paths.user.to_string_lossy().to_string()), + }, project: ProviderSourceInfo { exists: project_exists, - path: layers.paths.project.as_ref().map(|p| p.to_string_lossy().to_string()), + path: layers + .paths + .project + .as_ref() + .map(|p| p.to_string_lossy().to_string()), }, custom: ProviderSourceInfo { exists: custom_exists, - path: layers.paths.custom.as_ref().map(|p| p.to_string_lossy().to_string()), + path: layers + .paths + .custom + .as_ref() + .map(|p| p.to_string_lossy().to_string()), }, }) } @@ -432,10 +449,7 @@ pub async fn remove_provider_config( let mut remove_provider_key = false; let mut remove_providers_key = false; - if let Some(provider_section) = config - .get_mut("provider") - .and_then(|v| v.as_object_mut()) - { + if let Some(provider_section) = config.get_mut("provider").and_then(|v| v.as_object_mut()) { if provider_section.remove(provider_id).is_some() { removed = true; if provider_section.is_empty() { @@ -444,10 +458,7 @@ pub async fn remove_provider_config( } } - if let Some(provider_section) = config - .get_mut("providers") - .and_then(|v| v.as_object_mut()) - { + if let Some(provider_section) = config.get_mut("providers").and_then(|v| v.as_object_mut()) { if provider_section.remove(provider_id).is_some() { removed = true; if provider_section.is_empty() { @@ -485,7 +496,8 @@ fn get_legacy_project_agent_dir(working_directory: &Path) -> PathBuf { /// Get project-level agent path fn get_project_agent_path(working_directory: &Path, agent_name: &str) -> PathBuf { let plural_path = get_project_agent_dir(working_directory).join(format!("{}.md", agent_name)); - let legacy_path = get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name)); + let legacy_path = + get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name)); if legacy_path.exists() && !plural_path.exists() { return legacy_path; } @@ -566,8 +578,10 @@ fn get_legacy_project_command_dir(working_directory: &Path) -> PathBuf { /// Get project-level command path fn get_project_command_path(working_directory: &Path, command_name: &str) -> PathBuf { - let plural_path = get_project_command_dir(working_directory).join(format!("{}.md", command_name)); - let legacy_path = get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name)); + let plural_path = + get_project_command_dir(working_directory).join(format!("{}.md", command_name)); + let legacy_path = + get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name)); if legacy_path.exists() && !plural_path.exists() { return legacy_path; } diff --git a/packages/desktop/src-tauri/src/skills_catalog.rs b/packages/desktop/src-tauri/src/skills_catalog.rs index 54750a1f..eec8b531 100644 --- a/packages/desktop/src-tauri/src/skills_catalog.rs +++ b/packages/desktop/src-tauri/src/skills_catalog.rs @@ -791,7 +791,12 @@ async fn scan_clawdhub() -> Result> { for _ in 0..max_pages { let url = match &cursor { - Some(c) => format!("{}{}?cursor={}", CLAWDHUB_API_BASE, "/skills", urlencoding::encode(c)), + Some(c) => format!( + "{}{}?cursor={}", + CLAWDHUB_API_BASE, + "/skills", + urlencoding::encode(c) + ), None => format!("{}/skills", CLAWDHUB_API_BASE), }; @@ -1440,7 +1445,11 @@ async fn install_skills_from_clawdhub( // Check for conflicts first let mut conflicts = vec![]; for sel in &req.selections { - let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir); + let slug = sel + .clawdhub + .as_ref() + .map(|c| c.slug.as_str()) + .unwrap_or(&sel.skill_dir); if !validate_skill_name(slug) { continue; } @@ -1478,8 +1487,17 @@ async fn install_skills_from_clawdhub( } for sel in &req.selections { - let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir); - let mut version = sel.clawdhub.as_ref().map(|c| c.version.as_str()).unwrap_or("latest").to_string(); + let slug = sel + .clawdhub + .as_ref() + .map(|c| c.slug.as_str()) + .unwrap_or(&sel.skill_dir); + let mut version = sel + .clawdhub + .as_ref() + .map(|c| c.version.as_str()) + .unwrap_or("latest") + .to_string(); if !validate_skill_name(slug) { skipped.push(SkippedSkill { @@ -1548,7 +1566,8 @@ async fn install_skills_from_clawdhub( // Download and extract match download_clawdhub_skill(slug, &version).await { Ok(zip_data) => { - let temp_dir = std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4())); + let temp_dir = + std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4())); let _ = tokio::fs::remove_dir_all(&temp_dir).await; // Extract ZIP using the zip crate diff --git a/packages/desktop/src/api/git.ts b/packages/desktop/src/api/git.ts index 90293e32..87660d2f 100644 --- a/packages/desktop/src/api/git.ts +++ b/packages/desktop/src/api/git.ts @@ -10,6 +10,7 @@ import type { GitDeleteBranchPayload, GitDeleteRemoteBranchPayload, GeneratedCommitMessage, + GeneratedPullRequestDescription, GitWorktreeInfo, GitAddWorktreePayload, GitRemoveWorktreePayload, @@ -108,6 +109,17 @@ export const createDesktopGitAPI = (): GitAPI => ({ return response; }, + async generatePullRequestDescription( + directory: string, + payload: { base: string; head: string } + ): Promise { + return safeGitInvoke('generate_pr_description', { + directory, + base: payload.base, + head: payload.head, + }); + }, + async listGitWorktrees(directory: string): Promise { return safeGitInvoke('list_git_worktrees', { directory }); }, diff --git a/packages/desktop/src/api/github.ts b/packages/desktop/src/api/github.ts new file mode 100644 index 00000000..00300c5f --- /dev/null +++ b/packages/desktop/src/api/github.ts @@ -0,0 +1,62 @@ +import type { + GitHubAPI, + GitHubAuthStatus, + GitHubPullRequest, + GitHubPullRequestCreateInput, + GitHubPullRequestMergeInput, + GitHubPullRequestMergeResult, + GitHubPullRequestReadyInput, + GitHubPullRequestReadyResult, + GitHubPullRequestStatus, + GitHubDeviceFlowComplete, + GitHubDeviceFlowStart, + GitHubUserSummary, +} from '@openchamber/ui/lib/api/types'; + +export const createDesktopGitHubAPI = (): GitHubAPI => ({ + async authStatus(): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_auth_status', {}, { timeout: 8000 }); + }, + + async authStart(): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_auth_start', {}, { timeout: 8000 }); + }, + + async authComplete(deviceCode: string): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_auth_complete', { deviceCode }, { timeout: 12000 }); + }, + + async authDisconnect(): Promise<{ removed: boolean }> { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + const result = await safeInvoke<{ removed: boolean }>('github_auth_disconnect', {}, { timeout: 8000 }); + return { removed: Boolean(result?.removed) }; + }, + + async me(): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_me', {}, { timeout: 8000 }); + }, + + async prStatus(directory: string, branch: string): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_pr_status', { directory, branch }, { timeout: 12000 }); + }, + + async prCreate(payload: GitHubPullRequestCreateInput): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_pr_create', payload, { timeout: 20000 }); + }, + + async prMerge(payload: GitHubPullRequestMergeInput): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_pr_merge', payload, { timeout: 20000 }); + }, + + async prReady(payload: GitHubPullRequestReadyInput): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_pr_ready', payload, { timeout: 20000 }); + }, +}); diff --git a/packages/desktop/src/api/index.ts b/packages/desktop/src/api/index.ts index ff926b7f..f102c4a2 100644 --- a/packages/desktop/src/api/index.ts +++ b/packages/desktop/src/api/index.ts @@ -7,6 +7,7 @@ import { createDesktopPermissionsAPI } from './permissions'; import { createDesktopDiagnosticsAPI } from './diagnostics'; import { createDesktopNotificationsAPI } from './notifications'; import { createDesktopToolsAPI } from './tools'; +import { createDesktopGitHubAPI } from './github'; const activeTerminalConnections = new Set(); @@ -39,6 +40,7 @@ export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => { settings: createDesktopSettingsAPI(), permissions: createDesktopPermissionsAPI(), notifications: createDesktopNotificationsAPI(), + github: createDesktopGitHubAPI(), diagnostics: createDesktopDiagnosticsAPI(), tools: createDesktopToolsAPI(), cleanup: () => { diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx new file mode 100644 index 00000000..f110017f --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -0,0 +1,332 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { RiGithubFill } from '@remixicon/react'; + +type GitHubUser = { + login: string; + id?: number; + avatarUrl?: string; + name?: string; + email?: string; +}; + +type AuthStatusResponse = { + connected: boolean; + user?: GitHubUser | null; + scope?: string; + error?: string; +}; + +type DeviceFlowStartResponse = { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresIn: number; + interval: number; + scope?: string; +}; + +type DeviceFlowCompleteResponse = + | { connected: true; user: GitHubUser; scope?: string } + | { connected: false; status?: string; error?: string }; + +export const GitHubSettings: React.FC = () => { + const runtimeGitHub = getRegisteredRuntimeAPIs()?.github; + + const openExternal = React.useCallback(async (url: string) => { + if (typeof window === 'undefined') { + return; + } + + const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise } }).opencodeDesktop; + if (desktop?.openExternal) { + try { + await desktop.openExternal(url); + return; + } catch { + // fall through + } + } + + try { + window.open(url, '_blank', 'noopener,noreferrer'); + } catch { + // ignore + } + }, []); + + const [isLoading, setIsLoading] = React.useState(true); + const [isBusy, setIsBusy] = React.useState(false); + const [status, setStatus] = React.useState(null); + const [flow, setFlow] = React.useState(null); + const [pollIntervalMs, setPollIntervalMs] = React.useState(null); + const pollTimerRef = React.useRef(null); + + const stopPolling = React.useCallback(() => { + if (pollTimerRef.current != null) { + window.clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + setPollIntervalMs(null); + }, []); + + const refreshStatus = React.useCallback(async () => { + if (runtimeGitHub) { + const payload = await runtimeGitHub.authStatus(); + setStatus(payload as AuthStatusResponse); + return payload as AuthStatusResponse; + } + + const response = await fetch('/api/github/auth/status', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = (await response.json().catch(() => null)) as AuthStatusResponse | null; + if (!response.ok || !payload) { + throw new Error(payload?.error || response.statusText || 'Failed to load GitHub status'); + } + setStatus(payload); + return payload; + }, [runtimeGitHub]); + + React.useEffect(() => { + let mounted = true; + (async () => { + try { + await refreshStatus(); + } catch (error) { + console.warn('Failed to load GitHub auth status:', error); + } finally { + if (mounted) setIsLoading(false); + } + })(); + return () => { + mounted = false; + stopPolling(); + }; + }, [refreshStatus, stopPolling]); + + const startConnect = React.useCallback(async () => { + setIsBusy(true); + try { + const payload = runtimeGitHub + ? await runtimeGitHub.authStart() + : await (async () => { + const response = await fetch('/api/github/auth/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({}), + }); + const body = (await response.json().catch(() => null)) as DeviceFlowStartResponse | { error?: string } | null; + if (!response.ok || !body || !('deviceCode' in body)) { + throw new Error((body as { error?: string } | null)?.error || response.statusText); + } + return body; + })(); + + setFlow(payload); + setPollIntervalMs(Math.max(1, payload.interval) * 1000); + + const url = payload.verificationUriComplete || payload.verificationUri; + void openExternal(url); + } catch (error) { + console.error('Failed to start GitHub connect:', error); + toast.error('Failed to start GitHub connect'); + } finally { + setIsBusy(false); + } + }, [openExternal, runtimeGitHub]); + + const pollOnce = React.useCallback(async (deviceCode: string) => { + if (runtimeGitHub) { + return runtimeGitHub.authComplete(deviceCode) as Promise; + } + + const response = await fetch('/api/github/auth/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ deviceCode }), + }); + + const payload = (await response.json().catch(() => null)) as DeviceFlowCompleteResponse | { error?: string } | null; + if (!response.ok || !payload) { + throw new Error((payload as { error?: string } | null)?.error || response.statusText); + } + return payload as DeviceFlowCompleteResponse; + }, [runtimeGitHub]); + + React.useEffect(() => { + if (!flow?.deviceCode || !pollIntervalMs) { + return; + } + if (pollTimerRef.current != null) { + return; + } + + pollTimerRef.current = window.setInterval(() => { + void (async () => { + try { + const result = await pollOnce(flow.deviceCode); + if (result.connected) { + toast.success('GitHub connected'); + setFlow(null); + stopPolling(); + await refreshStatus(); + return; + } + + if (result.status === 'slow_down') { + setPollIntervalMs((prev) => (prev ? prev + 5000 : 5000)); + } + + if (result.status === 'expired_token' || result.status === 'access_denied') { + toast.error(result.error || 'GitHub authorization failed'); + setFlow(null); + stopPolling(); + } + } catch (error) { + console.warn('GitHub polling failed:', error); + } + })(); + }, pollIntervalMs); + + return () => { + if (pollTimerRef.current != null) { + window.clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + }, [flow, pollIntervalMs, pollOnce, refreshStatus, stopPolling]); + + const disconnect = React.useCallback(async () => { + setIsBusy(true); + try { + stopPolling(); + setFlow(null); + if (runtimeGitHub) { + await runtimeGitHub.authDisconnect(); + } else { + const response = await fetch('/api/github/auth', { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(response.statusText); + } + } + toast.success('GitHub disconnected'); + await refreshStatus(); + } catch (error) { + console.error('Failed to disconnect GitHub:', error); + toast.error('Failed to disconnect GitHub'); + } finally { + setIsBusy(false); + } + }, [refreshStatus, stopPolling, runtimeGitHub]); + + if (isLoading) { + return null; + } + + const connected = Boolean(status?.connected); + const user = status?.user; + + return ( +
+
+

GitHub

+

+ Connect a GitHub account for in-app PR and issue workflows. +

+
+ + {connected ? ( +
+
+ {user?.avatarUrl ? ( + {user.login + ) : ( +
+ )} + +
+
+ {user?.name?.trim() || user?.login || 'GitHub'} +
+ {user?.email ? ( +
{user.email}
+ ) : null} +
+ + {user?.login || 'unknown'} +
+ {status?.scope ? ( +
Scopes: {status.scope}
+ ) : null} +
+
+ + +
+ ) : ( +
+
Not connected
+ +
+ )} + + {flow ? ( +
+
+
Authorize OpenChamber
+
+ In GitHub, enter this code: +
+
+
+
{flow.userCode}
+ +
+
+ Waiting for approval… (auto-refresh) +
+
+ +
+
+ ) : null} +
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index cf513224..9cd9ffd6 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -7,6 +7,7 @@ import { DefaultsSettings } from './DefaultsSettings'; import { GitSettings } from './GitSettings'; import { WorktreeSectionContent } from './WorktreeSectionContent'; import { NotificationSettings } from './NotificationSettings'; +import { GitHubSettings } from './GitHubSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; import { isWebRuntime } from '@/lib/desktop'; @@ -56,6 +57,8 @@ export const OpenChamberPage: React.FC = ({ section }) => return ; case 'git': return ; + case 'github': + return ; case 'notifications': return ; default: @@ -117,6 +120,11 @@ const GitSectionContent: React.FC = () => { ); }; +// GitHub section: Connect account for PR/issue workflows +const GitHubSectionContent: React.FC = () => { + return ; +}; + // Notifications section: Native browser notifications const NotificationSectionContent: React.FC = () => { return ; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx index ade15961..c9a15ba2 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx @@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { AboutSettings } from './AboutSettings'; import { cn } from '@/lib/utils'; -export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'notifications'; +export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'github' | 'notifications'; interface OpenChamberSidebarProps { selectedSection: OpenChamberSection; @@ -42,6 +42,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [ items: ['Commit Messages', 'Worktree'], hideInVSCode: true, }, + { + id: 'github', + label: 'GitHub', + items: ['Connect', 'PRs', 'Issues'], + }, { id: 'notifications', label: 'Notifications', diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index a56bbc92..4ece2ef5 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -5,6 +5,7 @@ import { useFireworksCelebration } from '@/contexts/FireworksContext'; import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitStore, useGitStatus, @@ -39,6 +40,7 @@ import { GitEmptyState } from './git/GitEmptyState'; import { ChangesSection } from './git/ChangesSection'; import { CommitSection } from './git/CommitSection'; import { HistorySection } from './git/HistorySection'; +import { PullRequestSection } from './git/PullRequestSection'; type SyncAction = 'fetch' | 'pull' | 'push' | null; type CommitAction = 'commit' | 'commitAndPush' | null; @@ -231,6 +233,15 @@ export const GitView: React.FC = () => { const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled); + const activeProject = useProjectsStore((state) => state.getActiveProject()); + const baseBranch = React.useMemo(() => { + const fromProject = activeProject?.worktreeDefaults?.baseBranch; + if (typeof fromProject === 'string' && fromProject.trim().length > 0) { + return fromProject.trim(); + } + return 'main'; + }, [activeProject?.worktreeDefaults?.baseBranch]); + const [commitMessage, setCommitMessage] = React.useState( initialSnapshot?.commitMessage ?? '' ); @@ -1038,6 +1049,14 @@ export const GitView: React.FC = () => { )}
+ {currentDirectory && status?.current ? ( + + ) : null} + {/* History below, constrained width */} = ({ )}
- - - - - - Generate commit message with AI - - +
diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx new file mode 100644 index 00000000..aba07d0f --- /dev/null +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -0,0 +1,440 @@ +import React from 'react'; +import { + RiAiGenerate2, + RiCheckboxBlankLine, + RiCheckboxLine, + RiExternalLinkLine, + RiGitPullRequestLine, + RiLoader4Line, +} from '@remixicon/react'; +import { toast } from '@/components/ui'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { generatePullRequestDescription } from '@/lib/gitApi'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import type { + GitHubPullRequest, + GitHubPullRequestStatus, +} from '@/lib/api/types'; + +type MergeMethod = 'merge' | 'squash' | 'rebase'; + +const statusColor = (state: string | undefined | null): string => { + switch (state) { + case 'success': + return 'bg-[color:var(--status-success)]'; + case 'failure': + return 'bg-[color:var(--status-error)]'; + case 'pending': + return 'bg-[color:var(--status-warning)]'; + default: + return 'bg-muted-foreground/40'; + } +}; + +const branchToTitle = (branch: string): string => { + return branch + .replace(/^refs\/heads\//, '') + .replace(/[-_]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()); +}; + +const openExternal = async (url: string) => { + if (typeof window === 'undefined') return; + const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise } }).opencodeDesktop; + if (desktop?.openExternal) { + try { + await desktop.openExternal(url); + return; + } catch { + // fall through + } + } + try { + window.open(url, '_blank', 'noopener,noreferrer'); + } catch { + // ignore + } +}; + +export const PullRequestSection: React.FC<{ + directory: string; + branch: string; + baseBranch: string; +}> = ({ directory, branch, baseBranch }) => { + const { github } = useRuntimeAPIs(); + + const [isOpen, setIsOpen] = React.useState(true); + const [isLoading, setIsLoading] = React.useState(false); + const [status, setStatus] = React.useState(null); + const [error, setError] = React.useState(null); + + const [title, setTitle] = React.useState(() => branchToTitle(branch)); + const [body, setBody] = React.useState(''); + const [draft, setDraft] = React.useState(false); + const [mergeMethod, setMergeMethod] = React.useState('squash'); + + const [isGenerating, setIsGenerating] = React.useState(false); + const [isCreating, setIsCreating] = React.useState(false); + const [isMerging, setIsMerging] = React.useState(false); + const [isMarkingReady, setIsMarkingReady] = React.useState(false); + + const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch); + + const refresh = React.useCallback(async () => { + if (!canShow) return; + if (!github?.prStatus) { + setStatus(null); + setError('GitHub runtime API unavailable'); + return; + } + setIsLoading(true); + setError(null); + try { + const next = await github.prStatus(directory, branch); + setStatus(next); + if (next.connected === false) { + setError(null); + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setError(message || 'Failed to load PR status'); + } finally { + setIsLoading(false); + } + }, [branch, canShow, directory, github]); + + React.useEffect(() => { + setTitle(branchToTitle(branch)); + setBody(''); + setDraft(false); + void refresh(); + }, [branch, refresh]); + + const generateDescription = React.useCallback(async () => { + if (isGenerating) return; + if (!directory) return; + setIsGenerating(true); + try { + const generated = await generatePullRequestDescription(directory, { + base: baseBranch, + head: branch, + }); + + if (generated.title?.trim()) { + setTitle(generated.title.trim()); + } + if (generated.body?.trim()) { + setBody(generated.body.trim()); + } + toast.success('PR description generated'); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to generate description', { description: message }); + } finally { + setIsGenerating(false); + } + }, [baseBranch, branch, directory, isGenerating]); + + const createPr = React.useCallback(async () => { + if (!github?.prCreate) { + toast.error('GitHub runtime API unavailable'); + return; + } + const trimmedTitle = title.trim(); + if (!trimmedTitle) { + toast.error('Title is required'); + return; + } + + setIsCreating(true); + try { + const pr = await github.prCreate({ + directory, + title: trimmedTitle, + head: branch, + base: baseBranch, + ...(body.trim() ? { body } : {}), + draft, + }); + toast.success('PR created'); + setStatus((prev) => (prev ? { ...prev, pr } : prev)); + await refresh(); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to create PR', { description: message }); + } finally { + setIsCreating(false); + } + }, [baseBranch, body, branch, directory, draft, github, refresh, title]); + + const mergePr = React.useCallback(async (pr: GitHubPullRequest) => { + if (!github?.prMerge) { + toast.error('GitHub runtime API unavailable'); + return; + } + setIsMerging(true); + try { + const result = await github.prMerge({ directory, number: pr.number, method: mergeMethod }); + if (result.merged) { + toast.success('PR merged'); + } else { + toast.message('PR not merged', { description: result.message || 'Not mergeable' }); + } + await refresh(); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Merge failed', { description: message }); + if (pr.url) { + void openExternal(pr.url); + } + } finally { + setIsMerging(false); + } + }, [directory, github, mergeMethod, refresh]); + + const markReady = React.useCallback(async (pr: GitHubPullRequest) => { + if (!github?.prReady) { + toast.error('GitHub runtime API unavailable'); + return; + } + setIsMarkingReady(true); + try { + await github.prReady({ directory, number: pr.number }); + toast.success('Marked ready for review'); + await refresh(); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to mark ready', { description: message }); + if (pr.url) { + void openExternal(pr.url); + } + } finally { + setIsMarkingReady(false); + } + }, [directory, github, refresh]); + + if (!canShow) { + return null; + } + + const pr = status?.pr ?? null; + const repoUrl = status?.repo?.url || null; + const checks = status?.checks ?? null; + const canMerge = Boolean(status?.canMerge); + const isConnected = Boolean(status?.connected); + + return ( + + +
+ +

Pull Request

+ {pr ? ( + #{pr.number} + ) : null} +
+
+ {isLoading ? : null} + {checks ? ( + + + {checks.total > 0 ? `${checks.success}/${checks.total}` : checks.state} + + ) : null} +
+
+ + +
+
+ {!isConnected ? ( +
+ GitHub not connected. Connect in Settings to create and merge PRs. +
+ ) : null} + + {error ? ( +
+
PR status unavailable
+
{error}
+ {repoUrl ? ( + + ) : null} +
+ ) : null} + + {pr ? ( +
+
+
+
{pr.title}
+
+ {pr.state}{pr.draft ? ' (draft)' : ''} + {pr.mergeable === false ? ' · not mergeable' : ''} + {typeof pr.mergeableState === 'string' && pr.mergeableState ? ` · ${pr.mergeableState}` : ''} +
+ {canMerge && pr.draft ? ( +
+ Draft PRs must be marked ready before merge. +
+ ) : null} + {!canMerge ? ( +
No merge permission; use Open in GitHub.
+ ) : null} +
+ +
+ + {canMerge && pr.draft && pr.state === 'open' ? ( + + ) : null} + {canMerge ? ( + <> + + + + ) : null} +
+
+
+ ) : ( +
+
+
+
Create PR
+
+ {branch} → {baseBranch} +
+
+ {repoUrl ? ( + + ) : null} +
+ + + +