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
This commit is contained in:
committed by
GitHub
parent
0e715be7d6
commit
463e9ec4e3
@@ -31,6 +31,7 @@
|
|||||||
"@heroui/theme": "^2.4.23",
|
"@heroui/theme": "^2.4.23",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "^1.1.19",
|
"@opencode-ai/sdk": "^1.1.19",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@openchamber/desktop",
|
"name": "@openchamber/desktop",
|
||||||
"version": "1.5.3",
|
"version": "1.5.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
@@ -117,7 +118,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.5.3",
|
"version": "1.5.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
@@ -211,7 +212,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.5.3",
|
"version": "1.5.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.1.19",
|
"@opencode-ai/sdk": "^1.1.19",
|
||||||
@@ -234,7 +235,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.5.3",
|
"version": "1.5.5",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"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=="],
|
"@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/desktop": ["@openchamber/desktop@workspace:packages/desktop"],
|
||||||
|
|
||||||
"@openchamber/ui": ["@openchamber/ui@workspace:packages/ui"],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||||
|
|||||||
@@ -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-<number>-<slug>` (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 `<head.ref>`
|
||||||
|
- 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`.
|
||||||
@@ -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.
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
"@heroui/theme": "^2.4.23",
|
"@heroui/theme": "^2.4.23",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "^1.1.19",
|
"@opencode-ai/sdk": "^1.1.19",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|||||||
Generated
+1
-1
@@ -2977,7 +2977,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.5.3"
|
version = "1.5.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -267,7 +267,6 @@ async fn handle_question_asked(
|
|||||||
properties: &Value,
|
properties: &Value,
|
||||||
notified_questions: &Mutex<HashSet<String>>,
|
notified_questions: &Mutex<HashSet<String>>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
let session_id = properties.get("sessionID").and_then(Value::as_str);
|
let session_id = properties.get("sessionID").and_then(Value::as_str);
|
||||||
let question_id = properties.get("id").and_then(Value::as_str);
|
let question_id = properties.get("id").and_then(Value::as_str);
|
||||||
|
|
||||||
|
|||||||
@@ -234,13 +234,11 @@ pub async fn list_directory(
|
|||||||
.output();
|
.output();
|
||||||
|
|
||||||
match output {
|
match output {
|
||||||
Ok(out) => {
|
Ok(out) => String::from_utf8_lossy(&out.stdout)
|
||||||
String::from_utf8_lossy(&out.stdout)
|
.lines()
|
||||||
.lines()
|
.map(|s| s.trim().to_string())
|
||||||
.map(|s| s.trim().to_string())
|
.filter(|s| !s.is_empty())
|
||||||
.filter(|s| !s.is_empty())
|
.collect(),
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
Err(_) => HashSet::new(),
|
Err(_) => HashSet::new(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -507,9 +505,13 @@ pub async fn delete_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
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())
|
let resolved_path = resolve_sandboxed_path(
|
||||||
.await
|
Some(trimmed.to_string()),
|
||||||
.map_err(|err| err.to_delete_message())?;
|
&workspace_roots,
|
||||||
|
default_root.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_delete_message())?;
|
||||||
|
|
||||||
let metadata = fs::metadata(&resolved_path)
|
let metadata = fs::metadata(&resolved_path)
|
||||||
.await
|
.await
|
||||||
@@ -544,9 +546,13 @@ pub async fn rename_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
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())
|
let resolved_old = resolve_sandboxed_path(
|
||||||
.await
|
Some(trimmed_old.to_string()),
|
||||||
.map_err(|err| err.to_rename_message())?;
|
&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())
|
let resolved_new = resolve_creatable_path(trimmed_new, &workspace_roots, default_root.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_rename_message())?;
|
.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 (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())
|
let resolved_path = resolve_sandboxed_path(
|
||||||
.await
|
Some(trimmed.to_string()),
|
||||||
.map_err(|_| "File not found or access denied".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)
|
let metadata = fs::metadata(&resolved_path)
|
||||||
.await
|
.await
|
||||||
@@ -932,9 +942,13 @@ pub async fn read_file_binary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
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())
|
let resolved_path = resolve_sandboxed_path(
|
||||||
.await
|
Some(trimmed.to_string()),
|
||||||
.map_err(|_| "File not found or access denied".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)
|
let metadata = fs::metadata(&resolved_path)
|
||||||
.await
|
.await
|
||||||
@@ -1041,14 +1055,8 @@ fn build_shell_path_command(shell: &str) -> Vec<String> {
|
|||||||
"-lic".to_string(),
|
"-lic".to_string(),
|
||||||
"source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(),
|
"source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(),
|
||||||
],
|
],
|
||||||
"fish" => vec![
|
"fish" => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()],
|
||||||
"-lic".to_string(),
|
_ => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".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 (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())
|
let resolved_cwd = resolve_sandboxed_path(
|
||||||
.await
|
Some(cwd_trimmed.to_string()),
|
||||||
.map_err(|_| "Working directory not found or access denied".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)
|
let metadata = fs::metadata(&resolved_cwd)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -2042,7 +2042,9 @@ pub async fn get_remote_url(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let remote_name = remote.unwrap_or_else(|| "origin".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()))
|
Ok(url.filter(|s| !s.is_empty()))
|
||||||
}
|
}
|
||||||
@@ -2162,7 +2164,11 @@ pub async fn set_git_identity(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.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() {
|
} else if auth_type == "token" && profile.host.is_some() {
|
||||||
run_git(&["config", "--local", "credential.helper", "store"], &root)
|
run_git(&["config", "--local", "credential.helper", "store"], &root)
|
||||||
.await
|
.await
|
||||||
@@ -2330,3 +2336,134 @@ Diff summary:
|
|||||||
last_error.unwrap_or_else(|| "unknown error".to_string())
|
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<serde_json::Value, String> {
|
||||||
|
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::<Vec<String>>()
|
||||||
|
};
|
||||||
|
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::<serde_json::Value>(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 }))
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
pub mod github;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
|
|||||||
@@ -129,7 +129,10 @@ pub async fn process_directory_selection(
|
|||||||
|
|
||||||
if let Some(obj) = settings.as_object_mut() {
|
if let Some(obj) = settings.as_object_mut() {
|
||||||
obj.insert("activeProjectId".to_string(), json!(project_id.clone()));
|
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)
|
(settings, project_id)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -233,6 +233,20 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
|
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") {
|
if let Some(Value::String(s)) = obj.get("defaultModel") {
|
||||||
let trimmed = s.trim();
|
let trimmed = s.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -304,7 +318,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
|
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
|
||||||
let parsed = n
|
let parsed = n
|
||||||
.as_u64()
|
.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));
|
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||||
if let Some(value) = parsed {
|
if let Some(value) = parsed {
|
||||||
let clamped = value.max(10).min(500);
|
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") {
|
if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") {
|
||||||
let parsed = n
|
let parsed = n
|
||||||
.as_u64()
|
.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));
|
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||||
if let Some(value) = parsed {
|
if let Some(value) = parsed {
|
||||||
let clamped = value.max(20).min(500);
|
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") {
|
if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") {
|
||||||
let parsed = n
|
let parsed = n
|
||||||
.as_u64()
|
.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));
|
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||||
if let Some(value) = parsed {
|
if let Some(value) = parsed {
|
||||||
let clamped = value.max(30).min(1000);
|
let clamped = value.max(30).min(1000);
|
||||||
|
|||||||
@@ -28,18 +28,26 @@ use axum::{
|
|||||||
routing::{any, get, post},
|
routing::{any, get, post},
|
||||||
Json, Router,
|
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::{
|
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,
|
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
|
||||||
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, get_commit_files,
|
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message,
|
||||||
get_current_git_identity, has_local_identity, get_git_branches, get_git_diff, get_git_file_diff,
|
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,
|
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,
|
git_fetch, git_pull, git_push, has_local_identity, is_linked_worktree, list_git_worktrees,
|
||||||
revert_git_file, set_git_identity, update_git_identity,
|
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::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::notifications::desktop_notify;
|
||||||
use commands::permissions::{
|
use commands::permissions::{
|
||||||
pick_directory, process_directory_selection, request_directory_access,
|
pick_directory, process_directory_selection, request_directory_access,
|
||||||
@@ -880,6 +888,7 @@ fn main() {
|
|||||||
set_git_identity,
|
set_git_identity,
|
||||||
discover_git_credentials,
|
discover_git_credentials,
|
||||||
generate_commit_message,
|
generate_commit_message,
|
||||||
|
generate_pr_description,
|
||||||
create_terminal_session,
|
create_terminal_session,
|
||||||
send_terminal_input,
|
send_terminal_input,
|
||||||
resize_terminal,
|
resize_terminal,
|
||||||
@@ -888,6 +897,15 @@ fn main() {
|
|||||||
force_kill_terminal,
|
force_kill_terminal,
|
||||||
fetch_desktop_logs,
|
fetch_desktop_logs,
|
||||||
desktop_notify,
|
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| {
|
.on_menu_event(|app, event| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -2256,7 +2274,9 @@ async fn handle_config_routes(
|
|||||||
resolve_project_directory(&state, None).await.ok()
|
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) => {
|
Ok(mut sources) => {
|
||||||
let auth = opencode_auth::get_provider_auth(trimmed).await;
|
let auth = opencode_auth::get_provider_auth(trimmed).await;
|
||||||
sources.auth.exists = auth.ok().flatten().is_some();
|
sources.auth.exists = auth.ok().flatten().is_some();
|
||||||
@@ -2324,13 +2344,30 @@ async fn handle_config_routes(
|
|||||||
let removal_result = if scope == "auth" {
|
let removal_result = if scope == "auth" {
|
||||||
opencode_auth::remove_provider_auth(trimmed).await
|
opencode_auth::remove_provider_auth(trimmed).await
|
||||||
} else if scope == "user" {
|
} 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" {
|
} 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" {
|
} 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" {
|
} 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(
|
let user_removed = opencode_config::remove_provider_config(
|
||||||
trimmed,
|
trimmed,
|
||||||
working_directory.as_deref(),
|
working_directory.as_deref(),
|
||||||
@@ -2500,7 +2537,10 @@ async fn change_directory_handler(
|
|||||||
"activeProjectId".to_string(),
|
"activeProjectId".to_string(),
|
||||||
Value::String(active_project_id.clone()),
|
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
|
settings
|
||||||
})
|
})
|
||||||
@@ -2645,7 +2685,6 @@ impl SettingsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub(crate) async fn update_with<R, F>(&self, f: F) -> Result<(Value, R)>
|
pub(crate) async fn update_with<R, F>(&self, f: F) -> Result<(Value, R)>
|
||||||
where
|
where
|
||||||
F: FnOnce(Value) -> (Value, R),
|
F: FnOnce(Value) -> (Value, R),
|
||||||
@@ -2653,7 +2692,9 @@ impl SettingsStore {
|
|||||||
let _lock = self.guard.lock().await;
|
let _lock = self.guard.lock().await;
|
||||||
|
|
||||||
let current = match fs::read(&self.path).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 => {
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
Value::Object(Default::default())
|
Value::Object(Default::default())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,10 @@ fn get_project_config_file(working_directory: &Path) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default to root opencode.json for new configs
|
// 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
|
/// Get custom config file path from OPENCODE_CONFIG env var
|
||||||
@@ -390,15 +393,29 @@ pub async fn get_provider_sources(
|
|||||||
.is_some();
|
.is_some();
|
||||||
|
|
||||||
Ok(ProviderSources {
|
Ok(ProviderSources {
|
||||||
auth: ProviderSourceInfo { exists: false, path: None },
|
auth: ProviderSourceInfo {
|
||||||
user: ProviderSourceInfo { exists: user_exists, path: Some(layers.paths.user.to_string_lossy().to_string()) },
|
exists: false,
|
||||||
|
path: None,
|
||||||
|
},
|
||||||
|
user: ProviderSourceInfo {
|
||||||
|
exists: user_exists,
|
||||||
|
path: Some(layers.paths.user.to_string_lossy().to_string()),
|
||||||
|
},
|
||||||
project: ProviderSourceInfo {
|
project: ProviderSourceInfo {
|
||||||
exists: project_exists,
|
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 {
|
custom: ProviderSourceInfo {
|
||||||
exists: custom_exists,
|
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_provider_key = false;
|
||||||
let mut remove_providers_key = false;
|
let mut remove_providers_key = false;
|
||||||
|
|
||||||
if let Some(provider_section) = config
|
if let Some(provider_section) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||||
.get_mut("provider")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
if provider_section.remove(provider_id).is_some() {
|
if provider_section.remove(provider_id).is_some() {
|
||||||
removed = true;
|
removed = true;
|
||||||
if provider_section.is_empty() {
|
if provider_section.is_empty() {
|
||||||
@@ -444,10 +458,7 @@ pub async fn remove_provider_config(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(provider_section) = config
|
if let Some(provider_section) = config.get_mut("providers").and_then(|v| v.as_object_mut()) {
|
||||||
.get_mut("providers")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
if provider_section.remove(provider_id).is_some() {
|
if provider_section.remove(provider_id).is_some() {
|
||||||
removed = true;
|
removed = true;
|
||||||
if provider_section.is_empty() {
|
if provider_section.is_empty() {
|
||||||
@@ -485,7 +496,8 @@ fn get_legacy_project_agent_dir(working_directory: &Path) -> PathBuf {
|
|||||||
/// Get project-level agent path
|
/// Get project-level agent path
|
||||||
fn get_project_agent_path(working_directory: &Path, agent_name: &str) -> PathBuf {
|
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 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() {
|
if legacy_path.exists() && !plural_path.exists() {
|
||||||
return legacy_path;
|
return legacy_path;
|
||||||
}
|
}
|
||||||
@@ -566,8 +578,10 @@ fn get_legacy_project_command_dir(working_directory: &Path) -> PathBuf {
|
|||||||
|
|
||||||
/// Get project-level command path
|
/// Get project-level command path
|
||||||
fn get_project_command_path(working_directory: &Path, command_name: &str) -> PathBuf {
|
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 plural_path =
|
||||||
let legacy_path = get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name));
|
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() {
|
if legacy_path.exists() && !plural_path.exists() {
|
||||||
return legacy_path;
|
return legacy_path;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -791,7 +791,12 @@ async fn scan_clawdhub() -> Result<Vec<SkillsCatalogItem>> {
|
|||||||
|
|
||||||
for _ in 0..max_pages {
|
for _ in 0..max_pages {
|
||||||
let url = match &cursor {
|
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),
|
None => format!("{}/skills", CLAWDHUB_API_BASE),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1440,7 +1445,11 @@ async fn install_skills_from_clawdhub(
|
|||||||
// Check for conflicts first
|
// Check for conflicts first
|
||||||
let mut conflicts = vec![];
|
let mut conflicts = vec![];
|
||||||
for sel in &req.selections {
|
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) {
|
if !validate_skill_name(slug) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1478,8 +1487,17 @@ async fn install_skills_from_clawdhub(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for sel in &req.selections {
|
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
|
||||||
let mut version = sel.clawdhub.as_ref().map(|c| c.version.as_str()).unwrap_or("latest").to_string();
|
.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) {
|
if !validate_skill_name(slug) {
|
||||||
skipped.push(SkippedSkill {
|
skipped.push(SkippedSkill {
|
||||||
@@ -1548,7 +1566,8 @@ async fn install_skills_from_clawdhub(
|
|||||||
// Download and extract
|
// Download and extract
|
||||||
match download_clawdhub_skill(slug, &version).await {
|
match download_clawdhub_skill(slug, &version).await {
|
||||||
Ok(zip_data) => {
|
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;
|
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
|
||||||
|
|
||||||
// Extract ZIP using the zip crate
|
// Extract ZIP using the zip crate
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
GitDeleteBranchPayload,
|
GitDeleteBranchPayload,
|
||||||
GitDeleteRemoteBranchPayload,
|
GitDeleteRemoteBranchPayload,
|
||||||
GeneratedCommitMessage,
|
GeneratedCommitMessage,
|
||||||
|
GeneratedPullRequestDescription,
|
||||||
GitWorktreeInfo,
|
GitWorktreeInfo,
|
||||||
GitAddWorktreePayload,
|
GitAddWorktreePayload,
|
||||||
GitRemoveWorktreePayload,
|
GitRemoveWorktreePayload,
|
||||||
@@ -108,6 +109,17 @@ export const createDesktopGitAPI = (): GitAPI => ({
|
|||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async generatePullRequestDescription(
|
||||||
|
directory: string,
|
||||||
|
payload: { base: string; head: string }
|
||||||
|
): Promise<GeneratedPullRequestDescription> {
|
||||||
|
return safeGitInvoke<GeneratedPullRequestDescription>('generate_pr_description', {
|
||||||
|
directory,
|
||||||
|
base: payload.base,
|
||||||
|
head: payload.head,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
async listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
||||||
return safeGitInvoke<GitWorktreeInfo[]>('list_git_worktrees', { directory });
|
return safeGitInvoke<GitWorktreeInfo[]>('list_git_worktrees', { directory });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<GitHubAuthStatus> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubAuthStatus>('github_auth_status', {}, { timeout: 8000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async authStart(): Promise<GitHubDeviceFlowStart> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubDeviceFlowStart>('github_auth_start', {}, { timeout: 8000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubDeviceFlowComplete>('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<GitHubUserSummary> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubUserSummary>('github_me', {}, { timeout: 8000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubPullRequestStatus>('github_pr_status', { directory, branch }, { timeout: 12000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubPullRequest>('github_pr_create', payload, { timeout: 20000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubPullRequestMergeResult>('github_pr_merge', payload, { timeout: 20000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult> {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { createDesktopPermissionsAPI } from './permissions';
|
|||||||
import { createDesktopDiagnosticsAPI } from './diagnostics';
|
import { createDesktopDiagnosticsAPI } from './diagnostics';
|
||||||
import { createDesktopNotificationsAPI } from './notifications';
|
import { createDesktopNotificationsAPI } from './notifications';
|
||||||
import { createDesktopToolsAPI } from './tools';
|
import { createDesktopToolsAPI } from './tools';
|
||||||
|
import { createDesktopGitHubAPI } from './github';
|
||||||
|
|
||||||
const activeTerminalConnections = new Set<string>();
|
const activeTerminalConnections = new Set<string>();
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => {
|
|||||||
settings: createDesktopSettingsAPI(),
|
settings: createDesktopSettingsAPI(),
|
||||||
permissions: createDesktopPermissionsAPI(),
|
permissions: createDesktopPermissionsAPI(),
|
||||||
notifications: createDesktopNotificationsAPI(),
|
notifications: createDesktopNotificationsAPI(),
|
||||||
|
github: createDesktopGitHubAPI(),
|
||||||
diagnostics: createDesktopDiagnosticsAPI(),
|
diagnostics: createDesktopDiagnosticsAPI(),
|
||||||
tools: createDesktopToolsAPI(),
|
tools: createDesktopToolsAPI(),
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
|
|||||||
@@ -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<unknown> } }).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<AuthStatusResponse | null>(null);
|
||||||
|
const [flow, setFlow] = React.useState<DeviceFlowStartResponse | null>(null);
|
||||||
|
const [pollIntervalMs, setPollIntervalMs] = React.useState<number | null>(null);
|
||||||
|
const pollTimerRef = React.useRef<number | null>(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<DeviceFlowCompleteResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="typography-ui-header font-semibold text-foreground">GitHub</h3>
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
Connect a GitHub account for in-app PR and issue workflows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connected ? (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg border bg-background/50 px-4 py-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
|
{user?.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt={user.login ? `${user.login} avatar` : 'GitHub avatar'}
|
||||||
|
className="h-14 w-14 shrink-0 rounded-full border border-border/60 bg-muted object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-14 w-14 shrink-0 rounded-full border border-border/60 bg-muted" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="typography-ui-header font-semibold text-foreground truncate">
|
||||||
|
{user?.name?.trim() || user?.login || 'GitHub'}
|
||||||
|
</div>
|
||||||
|
{user?.email ? (
|
||||||
|
<div className="typography-body text-muted-foreground truncate">{user.email}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-1 flex items-center gap-2 typography-meta text-muted-foreground truncate">
|
||||||
|
<RiGithubFill className="h-4 w-4" />
|
||||||
|
<span className="font-mono">{user?.login || 'unknown'}</span>
|
||||||
|
</div>
|
||||||
|
{status?.scope ? (
|
||||||
|
<div className="typography-micro text-muted-foreground truncate">Scopes: {status.scope}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={disconnect} disabled={isBusy}>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border bg-background/50 px-3 py-2">
|
||||||
|
<div className="typography-ui-label text-foreground">Not connected</div>
|
||||||
|
<Button onClick={startConnect} disabled={isBusy}>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flow ? (
|
||||||
|
<div className="space-y-3 rounded-lg border bg-background/50 p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="typography-ui-label text-foreground">Authorize OpenChamber</div>
|
||||||
|
<div className="typography-meta text-muted-foreground">
|
||||||
|
In GitHub, enter this code:
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-mono text-lg tracking-widest text-foreground">{flow.userCode}</div>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a
|
||||||
|
href={flow.verificationUriComplete || flow.verificationUri}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Open GitHub
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="typography-micro text-muted-foreground">
|
||||||
|
Waiting for approval… (auto-refresh)
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" disabled={isBusy} onClick={() => {
|
||||||
|
stopPolling();
|
||||||
|
setFlow(null);
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { DefaultsSettings } from './DefaultsSettings';
|
|||||||
import { GitSettings } from './GitSettings';
|
import { GitSettings } from './GitSettings';
|
||||||
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
||||||
import { NotificationSettings } from './NotificationSettings';
|
import { NotificationSettings } from './NotificationSettings';
|
||||||
|
import { GitHubSettings } from './GitHubSettings';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isWebRuntime } from '@/lib/desktop';
|
import { isWebRuntime } from '@/lib/desktop';
|
||||||
@@ -56,6 +57,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
|||||||
return <SessionsSectionContent />;
|
return <SessionsSectionContent />;
|
||||||
case 'git':
|
case 'git':
|
||||||
return <GitSectionContent />;
|
return <GitSectionContent />;
|
||||||
|
case 'github':
|
||||||
|
return <GitHubSectionContent />;
|
||||||
case 'notifications':
|
case 'notifications':
|
||||||
return <NotificationSectionContent />;
|
return <NotificationSectionContent />;
|
||||||
default:
|
default:
|
||||||
@@ -117,6 +120,11 @@ const GitSectionContent: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GitHub section: Connect account for PR/issue workflows
|
||||||
|
const GitHubSectionContent: React.FC = () => {
|
||||||
|
return <GitHubSettings />;
|
||||||
|
};
|
||||||
|
|
||||||
// Notifications section: Native browser notifications
|
// Notifications section: Native browser notifications
|
||||||
const NotificationSectionContent: React.FC = () => {
|
const NotificationSectionContent: React.FC = () => {
|
||||||
return <NotificationSettings />;
|
return <NotificationSettings />;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
|||||||
import { AboutSettings } from './AboutSettings';
|
import { AboutSettings } from './AboutSettings';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'notifications';
|
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'github' | 'notifications';
|
||||||
|
|
||||||
interface OpenChamberSidebarProps {
|
interface OpenChamberSidebarProps {
|
||||||
selectedSection: OpenChamberSection;
|
selectedSection: OpenChamberSection;
|
||||||
@@ -42,6 +42,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
|
|||||||
items: ['Commit Messages', 'Worktree'],
|
items: ['Commit Messages', 'Worktree'],
|
||||||
hideInVSCode: true,
|
hideInVSCode: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'github',
|
||||||
|
label: 'GitHub',
|
||||||
|
items: ['Connect', 'PRs', 'Issues'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'notifications',
|
id: 'notifications',
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
|||||||
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
||||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import {
|
import {
|
||||||
useGitStore,
|
useGitStore,
|
||||||
useGitStatus,
|
useGitStatus,
|
||||||
@@ -39,6 +40,7 @@ import { GitEmptyState } from './git/GitEmptyState';
|
|||||||
import { ChangesSection } from './git/ChangesSection';
|
import { ChangesSection } from './git/ChangesSection';
|
||||||
import { CommitSection } from './git/CommitSection';
|
import { CommitSection } from './git/CommitSection';
|
||||||
import { HistorySection } from './git/HistorySection';
|
import { HistorySection } from './git/HistorySection';
|
||||||
|
import { PullRequestSection } from './git/PullRequestSection';
|
||||||
|
|
||||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||||
@@ -231,6 +233,15 @@ export const GitView: React.FC = () => {
|
|||||||
|
|
||||||
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
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(
|
const [commitMessage, setCommitMessage] = React.useState(
|
||||||
initialSnapshot?.commitMessage ?? ''
|
initialSnapshot?.commitMessage ?? ''
|
||||||
);
|
);
|
||||||
@@ -1038,6 +1049,14 @@ export const GitView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{currentDirectory && status?.current ? (
|
||||||
|
<PullRequestSection
|
||||||
|
directory={currentDirectory}
|
||||||
|
branch={status.current}
|
||||||
|
baseBranch={baseBranch}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* History below, constrained width */}
|
{/* History below, constrained width */}
|
||||||
<HistorySection
|
<HistorySection
|
||||||
log={log}
|
log={log}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from '@/components/ui/collapsible';
|
} from '@/components/ui/collapsible';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ButtonLarge } from '@/components/ui/button-large';
|
import { ButtonLarge } from '@/components/ui/button-large';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { CommitInput } from './CommitInput';
|
import { CommitInput } from './CommitInput';
|
||||||
import { AIHighlightsBox } from './AIHighlightsBox';
|
import { AIHighlightsBox } from './AIHighlightsBox';
|
||||||
|
|
||||||
@@ -97,32 +96,25 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip delayDuration={1000}>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
onClick={onGenerateMessage}
|
||||||
size="icon"
|
disabled={
|
||||||
className="h-8 w-8"
|
isGeneratingMessage ||
|
||||||
onClick={onGenerateMessage}
|
commitAction !== null ||
|
||||||
disabled={
|
selectedCount === 0 ||
|
||||||
isGeneratingMessage ||
|
isBusy
|
||||||
commitAction !== null ||
|
}
|
||||||
selectedCount === 0 ||
|
type="button"
|
||||||
isBusy
|
>
|
||||||
}
|
{isGeneratingMessage ? (
|
||||||
aria-label="Generate commit message"
|
<RiLoader4Line className="size-4 animate-spin" />
|
||||||
>
|
) : (
|
||||||
{isGeneratingMessage ? (
|
<RiAiGenerate2 className="size-4 text-primary" />
|
||||||
<RiLoader4Line className="size-4 animate-spin" />
|
)}
|
||||||
) : (
|
Generate
|
||||||
<RiAiGenerate2 className="size-4 text-primary" />
|
</Button>
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent sideOffset={8}>
|
|
||||||
Generate commit message with AI
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
|||||||
@@ -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<unknown> } }).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<GitHubPullRequestStatus | null>(null);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [title, setTitle] = React.useState(() => branchToTitle(branch));
|
||||||
|
const [body, setBody] = React.useState('');
|
||||||
|
const [draft, setDraft] = React.useState(false);
|
||||||
|
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('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 (
|
||||||
|
<Collapsible
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 h-10 hover:bg-transparent">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<RiGitPullRequestLine className="size-4 text-muted-foreground" />
|
||||||
|
<h3 className="typography-ui-header font-semibold text-foreground truncate">Pull Request</h3>
|
||||||
|
{pr ? (
|
||||||
|
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isLoading ? <RiLoader4Line className="size-4 animate-spin text-muted-foreground" /> : null}
|
||||||
|
{checks ? (
|
||||||
|
<span className="inline-flex items-center gap-2 typography-micro text-muted-foreground">
|
||||||
|
<span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} />
|
||||||
|
{checks.total > 0 ? `${checks.success}/${checks.total}` : checks.state}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t border-border/40">
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
{!isConnected ? (
|
||||||
|
<div className="typography-meta text-muted-foreground">
|
||||||
|
GitHub not connected. Connect in Settings to create and merge PRs.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="typography-ui-label text-foreground">PR status unavailable</div>
|
||||||
|
<div className="typography-meta text-muted-foreground break-words">{error}</div>
|
||||||
|
{repoUrl ? (
|
||||||
|
<Button variant="outline" size="sm" asChild className="w-fit">
|
||||||
|
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
Open Repo
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pr ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="typography-ui-label text-foreground truncate">{pr.title}</div>
|
||||||
|
<div className="typography-micro text-muted-foreground truncate">
|
||||||
|
{pr.state}{pr.draft ? ' (draft)' : ''}
|
||||||
|
{pr.mergeable === false ? ' · not mergeable' : ''}
|
||||||
|
{typeof pr.mergeableState === 'string' && pr.mergeableState ? ` · ${pr.mergeableState}` : ''}
|
||||||
|
</div>
|
||||||
|
{canMerge && pr.draft ? (
|
||||||
|
<div className="typography-micro text-muted-foreground">
|
||||||
|
Draft PRs must be marked ready before merge.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!canMerge ? (
|
||||||
|
<div className="typography-micro text-muted-foreground">No merge permission; use Open in GitHub.</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={pr.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{canMerge && pr.draft && pr.state === 'open' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => markReady(pr)}
|
||||||
|
disabled={isMarkingReady || isMerging}
|
||||||
|
>
|
||||||
|
{isMarkingReady ? <RiLoader4Line className="size-4 animate-spin" /> : null}
|
||||||
|
Ready
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canMerge ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-border bg-background px-2 typography-meta"
|
||||||
|
value={mergeMethod}
|
||||||
|
onChange={(e) => setMergeMethod(e.target.value as MergeMethod)}
|
||||||
|
disabled={isMerging || pr.state !== 'open'}
|
||||||
|
>
|
||||||
|
<option value="squash">Squash</option>
|
||||||
|
<option value="merge">Merge</option>
|
||||||
|
<option value="rebase">Rebase</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => mergePr(pr)}
|
||||||
|
disabled={isMerging || isMarkingReady || pr.state !== 'open' || pr.draft}
|
||||||
|
>
|
||||||
|
{isMerging ? <RiLoader4Line className="size-4 animate-spin" /> : null}
|
||||||
|
Merge
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="typography-ui-label text-foreground">Create PR</div>
|
||||||
|
<div className="typography-micro text-muted-foreground truncate">
|
||||||
|
{branch} → {baseBranch}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{repoUrl ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
Repo
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="space-y-1">
|
||||||
|
<div className="typography-micro text-muted-foreground">Title</div>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="PR title"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1">
|
||||||
|
<div className="typography-micro text-muted-foreground">Description</div>
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
className="min-h-[110px] bg-background/80"
|
||||||
|
placeholder="What changed and why"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={draft}
|
||||||
|
onClick={() => setDraft((v) => !v)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
setDraft((v) => !v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDraft((v) => !v);
|
||||||
|
}}
|
||||||
|
aria-label="Toggle draft PR"
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
{draft ? (
|
||||||
|
<RiCheckboxLine className="size-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<RiCheckboxBlankLine className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="typography-ui-label text-foreground select-none">Draft</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={generateDescription}
|
||||||
|
disabled={isGenerating || isCreating}
|
||||||
|
>
|
||||||
|
{isGenerating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiAiGenerate2 className="size-4 text-primary" />}
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button size="sm" onClick={createPr} disabled={isCreating || !isConnected}>
|
||||||
|
{isCreating ? <RiLoader4Line className="size-4 animate-spin" /> : null}
|
||||||
|
Create PR
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -269,6 +269,11 @@ export interface GeneratedCommitMessage {
|
|||||||
highlights: string[];
|
highlights: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeneratedPullRequestDescription {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitAPI {
|
export interface GitAPI {
|
||||||
checkIsGitRepository(directory: string): Promise<boolean>;
|
checkIsGitRepository(directory: string): Promise<boolean>;
|
||||||
getGitStatus(directory: string): Promise<GitStatus>;
|
getGitStatus(directory: string): Promise<GitStatus>;
|
||||||
@@ -280,6 +285,10 @@ export interface GitAPI {
|
|||||||
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
|
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
|
||||||
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
|
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
|
||||||
generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }>;
|
generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }>;
|
||||||
|
generatePullRequestDescription(
|
||||||
|
directory: string,
|
||||||
|
payload: { base: string; head: string }
|
||||||
|
): Promise<GeneratedPullRequestDescription>;
|
||||||
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
|
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
|
||||||
addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>;
|
addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>;
|
||||||
removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }>;
|
removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }>;
|
||||||
@@ -478,6 +487,112 @@ export interface PushAPI {
|
|||||||
setVisibility(payload: { visible: boolean }): Promise<{ ok: true } | null>;
|
setVisibility(payload: { visible: boolean }): Promise<{ ok: true } | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GitHubUserSummary = {
|
||||||
|
login: string;
|
||||||
|
id?: number;
|
||||||
|
avatarUrl?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubRepoRef = {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubChecksSummary = {
|
||||||
|
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failure: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequest = {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
state: 'open' | 'closed' | 'merged';
|
||||||
|
draft: boolean;
|
||||||
|
base: string;
|
||||||
|
head: string;
|
||||||
|
headSha?: string;
|
||||||
|
mergeable?: boolean | null;
|
||||||
|
mergeableState?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestStatus = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
branch?: string;
|
||||||
|
pr?: GitHubPullRequest | null;
|
||||||
|
checks?: GitHubChecksSummary | null;
|
||||||
|
canMerge?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestCreateInput = {
|
||||||
|
directory: string;
|
||||||
|
title: string;
|
||||||
|
head: string;
|
||||||
|
base: string;
|
||||||
|
body?: string;
|
||||||
|
draft?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestMergeInput = {
|
||||||
|
directory: string;
|
||||||
|
number: number;
|
||||||
|
method: 'merge' | 'squash' | 'rebase';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestReadyInput = {
|
||||||
|
directory: string;
|
||||||
|
number: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestReadyResult = {
|
||||||
|
ready: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestMergeResult = {
|
||||||
|
merged: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubAuthStatus = {
|
||||||
|
connected: boolean;
|
||||||
|
user?: GitHubUserSummary | null;
|
||||||
|
scope?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubDeviceFlowStart = {
|
||||||
|
deviceCode: string;
|
||||||
|
userCode: string;
|
||||||
|
verificationUri: string;
|
||||||
|
verificationUriComplete?: string;
|
||||||
|
expiresIn: number;
|
||||||
|
interval: number;
|
||||||
|
scope?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubDeviceFlowComplete =
|
||||||
|
| { connected: true; user: GitHubUserSummary; scope?: string }
|
||||||
|
| { connected: false; status?: string; error?: string };
|
||||||
|
|
||||||
|
export interface GitHubAPI {
|
||||||
|
authStatus(): Promise<GitHubAuthStatus>;
|
||||||
|
authStart(): Promise<GitHubDeviceFlowStart>;
|
||||||
|
authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete>;
|
||||||
|
authDisconnect(): Promise<{ removed: boolean }>;
|
||||||
|
me?(): Promise<GitHubUserSummary>;
|
||||||
|
|
||||||
|
prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus>;
|
||||||
|
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
|
||||||
|
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
|
||||||
|
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuntimeAPIs {
|
export interface RuntimeAPIs {
|
||||||
runtime: RuntimeDescriptor;
|
runtime: RuntimeDescriptor;
|
||||||
terminal: TerminalAPI;
|
terminal: TerminalAPI;
|
||||||
@@ -486,6 +601,7 @@ export interface RuntimeAPIs {
|
|||||||
settings: SettingsAPI;
|
settings: SettingsAPI;
|
||||||
permissions: PermissionsAPI;
|
permissions: PermissionsAPI;
|
||||||
notifications: NotificationsAPI;
|
notifications: NotificationsAPI;
|
||||||
|
github?: GitHubAPI;
|
||||||
push?: PushAPI;
|
push?: PushAPI;
|
||||||
diagnostics?: DiagnosticsAPI;
|
diagnostics?: DiagnosticsAPI;
|
||||||
tools: ToolsAPI;
|
tools: ToolsAPI;
|
||||||
|
|||||||
@@ -104,6 +104,17 @@ export async function generateCommitMessage(
|
|||||||
return gitHttp.generateCommitMessage(directory, files);
|
return gitHttp.generateCommitMessage(directory, files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generatePullRequestDescription(
|
||||||
|
directory: string,
|
||||||
|
payload: { base: string; head: string }
|
||||||
|
): Promise<import('./api/types').GeneratedPullRequestDescription> {
|
||||||
|
const runtime = getRuntimeGit();
|
||||||
|
if (runtime?.generatePullRequestDescription) {
|
||||||
|
return runtime.generatePullRequestDescription(directory, payload);
|
||||||
|
}
|
||||||
|
return gitHttp.generatePullRequestDescription(directory, payload);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
|
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
|
||||||
const runtime = getRuntimeGit();
|
const runtime = getRuntimeGit();
|
||||||
if (runtime) return runtime.listGitWorktrees(directory);
|
if (runtime) return runtime.listGitWorktrees(directory);
|
||||||
|
|||||||
@@ -248,6 +248,35 @@ export async function generateCommitMessage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generatePullRequestDescription(
|
||||||
|
directory: string,
|
||||||
|
payload: { base: string; head: string }
|
||||||
|
): Promise<{ title: string; body: string }> {
|
||||||
|
const { base, head } = payload;
|
||||||
|
if (!base || !head) {
|
||||||
|
throw new Error('base and head are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ base, head }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
|
throw new Error(error.error || 'Failed to generate PR description');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
const title = typeof data?.title === 'string' ? data.title : '';
|
||||||
|
const body = typeof data?.body === 'string' ? data.body : '';
|
||||||
|
if (!title && !body) {
|
||||||
|
throw new Error('Malformed PR description response');
|
||||||
|
}
|
||||||
|
return { title, body };
|
||||||
|
}
|
||||||
|
|
||||||
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
||||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory));
|
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ import {
|
|||||||
installSkillsFromRepository as installSkillsFromGit,
|
installSkillsFromRepository as installSkillsFromGit,
|
||||||
type SkillsCatalogSourceConfig,
|
type SkillsCatalogSourceConfig,
|
||||||
} from './skillsCatalog';
|
} from './skillsCatalog';
|
||||||
|
import {
|
||||||
|
DEFAULT_GITHUB_CLIENT_ID,
|
||||||
|
DEFAULT_GITHUB_SCOPES,
|
||||||
|
clearGitHubAuth,
|
||||||
|
exchangeDeviceCode,
|
||||||
|
fetchMe,
|
||||||
|
readGitHubAuth,
|
||||||
|
startDeviceFlow,
|
||||||
|
writeGitHubAuth,
|
||||||
|
} from './githubAuth';
|
||||||
|
import {
|
||||||
|
createPullRequest,
|
||||||
|
getPullRequestStatus,
|
||||||
|
markPullRequestReady,
|
||||||
|
mergePullRequest,
|
||||||
|
} from './githubPr';
|
||||||
|
|
||||||
export interface BridgeRequest {
|
export interface BridgeRequest {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -77,6 +93,67 @@ const readSettings = (ctx?: BridgeContext) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readStringField = (value: unknown, key: string): string => {
|
||||||
|
if (!value || typeof value !== 'object') return '';
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const candidate = record[key];
|
||||||
|
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBooleanField = (value: unknown, key: string): boolean | undefined => {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const candidate = record[key];
|
||||||
|
return typeof candidate === 'boolean' ? candidate : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readNumberField = (value: unknown, key: string): number | undefined => {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const candidate = record[key];
|
||||||
|
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMergeMethod = (value: string): 'merge' | 'squash' | 'rebase' => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === 'merge' || trimmed === 'squash' || trimmed === 'rebase') return trimmed;
|
||||||
|
return 'merge';
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractZenOutputText = (value: unknown): string | null => {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const root = value as Record<string, unknown>;
|
||||||
|
const output = root.output;
|
||||||
|
if (!Array.isArray(output)) return null;
|
||||||
|
|
||||||
|
const messageItem = output.find((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return false;
|
||||||
|
return (item as Record<string, unknown>).type === 'message';
|
||||||
|
}) as Record<string, unknown> | undefined;
|
||||||
|
if (!messageItem) return null;
|
||||||
|
|
||||||
|
const content = messageItem.content;
|
||||||
|
if (!Array.isArray(content)) return null;
|
||||||
|
|
||||||
|
const textItem = content.find((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return false;
|
||||||
|
return (item as Record<string, unknown>).type === 'output_text';
|
||||||
|
}) as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const text = typeof textItem?.text === 'string' ? textItem.text.trim() : '';
|
||||||
|
return text || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseJsonObjectSafe = (value: string): Record<string, unknown> | null => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext) => {
|
const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext) => {
|
||||||
const current = readSettings(ctx);
|
const current = readSettings(ctx);
|
||||||
const restChanges = { ...(changes || {}) };
|
const restChanges = { ...(changes || {}) };
|
||||||
@@ -877,6 +954,223 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
return { id, type, success: true, data: updated };
|
return { id, type, success: true, data: updated };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'api:github/auth:status': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const stored = await readGitHubAuth(context);
|
||||||
|
if (!stored?.accessToken) {
|
||||||
|
return { id, type, success: true, data: { connected: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await fetchMe(stored.accessToken);
|
||||||
|
return { id, type, success: true, data: { connected: true, user, scope: stored.scope } };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (status === 401 || message === 'unauthorized') {
|
||||||
|
await clearGitHubAuth(context);
|
||||||
|
return { id, type, success: true, data: { connected: false } };
|
||||||
|
}
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api:github/auth:start': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const settings = readSettings(ctx);
|
||||||
|
const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID;
|
||||||
|
const scopes = readStringField(settings, 'githubScopes') || DEFAULT_GITHUB_SCOPES;
|
||||||
|
const flow = await startDeviceFlow(clientId, scopes);
|
||||||
|
return { id, type, success: true, data: flow };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api:github/auth:complete': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const deviceCode = readStringField(payload, 'deviceCode');
|
||||||
|
if (!deviceCode) return { id, type, success: false, error: 'deviceCode is required' };
|
||||||
|
|
||||||
|
const settings = readSettings(ctx);
|
||||||
|
const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID;
|
||||||
|
|
||||||
|
const token = await exchangeDeviceCode(clientId, deviceCode);
|
||||||
|
const tokenRecord = token && typeof token === 'object' ? (token as Record<string, unknown>) : null;
|
||||||
|
const tokenError = typeof tokenRecord?.error === 'string' ? tokenRecord.error : '';
|
||||||
|
const tokenErrorDescription = typeof tokenRecord?.error_description === 'string' ? tokenRecord.error_description : '';
|
||||||
|
if (tokenError) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
connected: false,
|
||||||
|
status: tokenError,
|
||||||
|
error: tokenErrorDescription || tokenError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const accessToken = typeof tokenRecord?.access_token === 'string' ? tokenRecord.access_token : '';
|
||||||
|
if (!accessToken) {
|
||||||
|
return { id, type, success: false, error: 'Missing access_token from GitHub' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await fetchMe(accessToken);
|
||||||
|
await writeGitHubAuth(context, {
|
||||||
|
accessToken,
|
||||||
|
scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined,
|
||||||
|
tokenType: typeof tokenRecord?.token_type === 'string' ? tokenRecord.token_type : undefined,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
connected: true,
|
||||||
|
user,
|
||||||
|
scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api:github/auth:disconnect': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const removed = await clearGitHubAuth(context);
|
||||||
|
return { id, type, success: true, data: { removed } };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api:github/me': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const stored = await readGitHubAuth(context);
|
||||||
|
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
|
||||||
|
try {
|
||||||
|
const user = await fetchMe(stored.accessToken);
|
||||||
|
return { id, type, success: true, data: user };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (status === 401 || message === 'unauthorized') {
|
||||||
|
await clearGitHubAuth(context);
|
||||||
|
return { id, type, success: false, error: 'GitHub token expired or revoked' };
|
||||||
|
}
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api:github/pr:status': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const directory = readStringField(payload, 'directory');
|
||||||
|
const branch = readStringField(payload, 'branch');
|
||||||
|
if (!directory || !branch) {
|
||||||
|
return { id, type, success: false, error: 'directory and branch are required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = await readGitHubAuth(context);
|
||||||
|
if (!stored?.accessToken) {
|
||||||
|
return { id, type, success: true, data: { connected: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getPullRequestStatus(
|
||||||
|
stored.accessToken,
|
||||||
|
stored.user?.login || null,
|
||||||
|
directory,
|
||||||
|
branch,
|
||||||
|
);
|
||||||
|
if (result.connected === false) {
|
||||||
|
await clearGitHubAuth(context);
|
||||||
|
}
|
||||||
|
return { id, type, success: true, data: result };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api:github/pr:create': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const stored = await readGitHubAuth(context);
|
||||||
|
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
|
||||||
|
const directory = readStringField(payload, 'directory');
|
||||||
|
const title = readStringField(payload, 'title');
|
||||||
|
const head = readStringField(payload, 'head');
|
||||||
|
const base = readStringField(payload, 'base');
|
||||||
|
const body = readStringField(payload, 'body');
|
||||||
|
const draft = readBooleanField(payload, 'draft');
|
||||||
|
if (!directory || !title || !head || !base) {
|
||||||
|
return { id, type, success: false, error: 'directory, title, head, base are required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pr = await createPullRequest(stored.accessToken, directory, {
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
head,
|
||||||
|
base,
|
||||||
|
...(body ? { body } : {}),
|
||||||
|
...(typeof draft === 'boolean' ? { draft } : {}),
|
||||||
|
});
|
||||||
|
return { id, type, success: true, data: pr };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api:github/pr:merge': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const stored = await readGitHubAuth(context);
|
||||||
|
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
|
||||||
|
const directory = readStringField(payload, 'directory');
|
||||||
|
const method = normalizeMergeMethod(readStringField(payload, 'method') || 'merge');
|
||||||
|
const number = readNumberField(payload, 'number') ?? 0;
|
||||||
|
if (!directory || !number) {
|
||||||
|
return { id, type, success: false, error: 'directory and number are required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await mergePullRequest(stored.accessToken, directory, {
|
||||||
|
directory,
|
||||||
|
number,
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
return { id, type, success: true, data: result };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api:github/pr:ready': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const stored = await readGitHubAuth(context);
|
||||||
|
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
|
||||||
|
const directory = readStringField(payload, 'directory');
|
||||||
|
const number = readNumberField(payload, 'number') ?? 0;
|
||||||
|
if (!directory || !number) {
|
||||||
|
return { id, type, success: false, error: 'directory and number are required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await markPullRequestReady(stored.accessToken, directory, number);
|
||||||
|
return { id, type, success: true, data: result };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (status === 401 || message === 'unauthorized') {
|
||||||
|
await clearGitHubAuth(context);
|
||||||
|
}
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'api:config/reload': {
|
case 'api:config/reload': {
|
||||||
await ctx?.manager?.restart();
|
await ctx?.manager?.restart();
|
||||||
return { id, type, success: true, data: { restarted: true } };
|
return { id, type, success: true, data: { restarted: true } };
|
||||||
@@ -1614,6 +1908,91 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
return { id, type, success: true, data: result };
|
return { id, type, success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'api:git/pr-description': {
|
||||||
|
const { directory, base, head } = (payload || {}) as {
|
||||||
|
directory?: string;
|
||||||
|
base?: string;
|
||||||
|
head?: string;
|
||||||
|
};
|
||||||
|
if (!directory) {
|
||||||
|
return { id, type, success: false, error: 'Directory is required' };
|
||||||
|
}
|
||||||
|
if (!base || !head) {
|
||||||
|
return { id, type, success: false, error: 'base and head are required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect diffs (best-effort)
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
const listed = await gitService.getGitRangeFiles(directory, base, head);
|
||||||
|
files = Array.isArray(listed) ? listed : [];
|
||||||
|
} catch {
|
||||||
|
files = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return { id, type, success: false, error: 'No diffs available for base...head' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let diffSummaries = '';
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const diff = await gitService.getGitRangeDiff(directory, base, head, file, 3);
|
||||||
|
const raw = typeof diff?.diff === 'string' ? diff.diff : '';
|
||||||
|
if (!raw.trim()) continue;
|
||||||
|
diffSummaries += `FILE: ${file}\n${raw}\n\n`;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!diffSummaries.trim()) {
|
||||||
|
return { id, type, success: false, error: 'No diffs available for selected files' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `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:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}\n\nDiff summary:\n${diffSummaries}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://opencode.ai/zen/v1/responses', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-5-nano',
|
||||||
|
input: [{ role: 'user', content: prompt }],
|
||||||
|
max_output_tokens: 1200,
|
||||||
|
stream: false,
|
||||||
|
reasoning: { effort: 'low' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return { id, type, success: false, error: 'Failed to generate PR description' };
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => null) as unknown;
|
||||||
|
const raw = extractZenOutputText(data);
|
||||||
|
if (!raw) {
|
||||||
|
return { id, type, success: false, error: 'No PR description returned by generator' };
|
||||||
|
}
|
||||||
|
const cleaned = String(raw)
|
||||||
|
.trim()
|
||||||
|
.replace(/^```json\s*/i, '')
|
||||||
|
.replace(/^```\s*/i, '')
|
||||||
|
.replace(/```\s*$/i, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const parsed = parseJsonObjectSafe(cleaned) || parseJsonObjectSafe(raw);
|
||||||
|
if (parsed) {
|
||||||
|
const title = typeof parsed.title === 'string' ? parsed.title : '';
|
||||||
|
const body = typeof parsed.body === 'string' ? parsed.body : '';
|
||||||
|
return { id, type, success: true, data: { title, body } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, type, success: true, data: { title: '', body: String(raw) } };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'api:git/identity': {
|
case 'api:git/identity': {
|
||||||
const { directory, method, userName, userEmail, sshKey } = (payload || {}) as {
|
const { directory, method, userName, userEmail, sshKey } = (payload || {}) as {
|
||||||
directory?: string;
|
directory?: string;
|
||||||
|
|||||||
@@ -684,6 +684,48 @@ export async function getGitDiff(
|
|||||||
return { diff: result.stdout };
|
return { diff: result.stdout };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get diff between two refs for a file (base...head).
|
||||||
|
*/
|
||||||
|
export async function getGitRangeDiff(
|
||||||
|
directory: string,
|
||||||
|
base: string,
|
||||||
|
head: string,
|
||||||
|
filePath: string,
|
||||||
|
contextLines = 3
|
||||||
|
): Promise<{ diff: string }> {
|
||||||
|
const baseRef = (base || '').trim();
|
||||||
|
const headRef = (head || '').trim();
|
||||||
|
if (!baseRef || !headRef) {
|
||||||
|
return { diff: '' };
|
||||||
|
}
|
||||||
|
const args = ['diff', '--no-color', `-U${Math.max(0, contextLines)}`, `${baseRef}...${headRef}`, '--', filePath];
|
||||||
|
const result = await execGit(args, directory);
|
||||||
|
return { diff: result.stdout };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files changed between two refs (base...head).
|
||||||
|
*/
|
||||||
|
export async function getGitRangeFiles(
|
||||||
|
directory: string,
|
||||||
|
base: string,
|
||||||
|
head: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const baseRef = (base || '').trim();
|
||||||
|
const headRef = (head || '').trim();
|
||||||
|
if (!baseRef || !headRef) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const args = ['diff', '--name-only', `${baseRef}...${headRef}`];
|
||||||
|
const result = await execGit(args, directory);
|
||||||
|
if (result.exitCode !== 0) return [];
|
||||||
|
return String(result.stdout || '')
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get file diff with original and modified content
|
* Get file diff with original and modified content
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
|
||||||
|
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
||||||
|
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
||||||
|
const API_USER_URL = 'https://api.github.com/user';
|
||||||
|
const API_EMAILS_URL = 'https://api.github.com/user/emails';
|
||||||
|
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
|
||||||
|
export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
|
||||||
|
export const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
|
||||||
|
|
||||||
|
type StoredAuth = {
|
||||||
|
accessToken: string;
|
||||||
|
scope?: string;
|
||||||
|
tokenType?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
user?: { login: string; id?: number; avatarUrl?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
type DeviceCodeResponse = {
|
||||||
|
device_code: string;
|
||||||
|
user_code: string;
|
||||||
|
verification_uri: string;
|
||||||
|
verification_uri_complete?: string;
|
||||||
|
expires_in: number;
|
||||||
|
interval: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TokenResponse = {
|
||||||
|
access_token?: string;
|
||||||
|
scope?: string;
|
||||||
|
token_type?: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const authFilePath = (context: vscode.ExtensionContext) =>
|
||||||
|
path.join(context.globalStorageUri.fsPath, 'github-auth.json');
|
||||||
|
|
||||||
|
export const readGitHubAuth = async (context: vscode.ExtensionContext): Promise<StoredAuth | null> => {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(authFilePath(context), 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
|
const token = typeof parsed.accessToken === 'string' ? parsed.accessToken : '';
|
||||||
|
if (!token) return null;
|
||||||
|
return parsed as StoredAuth;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeGitHubAuth = async (context: vscode.ExtensionContext, auth: StoredAuth): Promise<void> => {
|
||||||
|
await fs.mkdir(context.globalStorageUri.fsPath, { recursive: true });
|
||||||
|
await fs.writeFile(authFilePath(context), JSON.stringify(auth, null, 2), 'utf8');
|
||||||
|
try {
|
||||||
|
// best-effort perms on unix
|
||||||
|
await fs.chmod(authFilePath(context), 0o600);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearGitHubAuth = async (context: vscode.ExtensionContext): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await fs.rm(authFilePath(context));
|
||||||
|
return true;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err && typeof err === 'object' && 'code' in err && (err as { code?: string }).code === 'ENOENT') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const postForm = async <T extends JsonRecord>(url: string, params: Record<string, string>): Promise<T> => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'OpenChamber',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(params).toString(),
|
||||||
|
});
|
||||||
|
const payload = (await response.json().catch(() => null)) as T | null;
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorDescription = typeof payload?.error_description === 'string' ? payload.error_description : '';
|
||||||
|
const error = typeof payload?.error === 'string' ? payload.error : '';
|
||||||
|
throw new Error(errorDescription || error || response.statusText);
|
||||||
|
}
|
||||||
|
return payload as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startDeviceFlow = async (clientId: string, scope: string) => {
|
||||||
|
const payload = await postForm<DeviceCodeResponse>(DEVICE_CODE_URL, { client_id: clientId, scope });
|
||||||
|
return {
|
||||||
|
deviceCode: payload.device_code,
|
||||||
|
userCode: payload.user_code,
|
||||||
|
verificationUri: payload.verification_uri,
|
||||||
|
verificationUriComplete: payload.verification_uri_complete,
|
||||||
|
expiresIn: payload.expires_in,
|
||||||
|
interval: payload.interval,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exchangeDeviceCode = async (clientId: string, deviceCode: string) => {
|
||||||
|
const payload = await postForm<TokenResponse>(ACCESS_TOKEN_URL, {
|
||||||
|
client_id: clientId,
|
||||||
|
device_code: deviceCode,
|
||||||
|
grant_type: DEVICE_GRANT_TYPE,
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchMe = async (accessToken: string) => {
|
||||||
|
const response = await fetch(API_USER_URL, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'User-Agent': 'OpenChamber',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
const error = new Error('unauthorized');
|
||||||
|
(error as unknown as { status?: number }).status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const payload = (await response.json().catch(() => null)) as JsonRecord | null;
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new Error(`GitHub /user failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof payload.name === 'string' ? payload.name : undefined;
|
||||||
|
let email = typeof payload.email === 'string' ? payload.email : undefined;
|
||||||
|
if (!email) {
|
||||||
|
try {
|
||||||
|
const emailsResponse = await fetch(API_EMAILS_URL, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'User-Agent': 'OpenChamber',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (emailsResponse.status === 401) {
|
||||||
|
const error = new Error('unauthorized');
|
||||||
|
(error as unknown as { status?: number }).status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const list = (await emailsResponse.json().catch(() => null)) as Array<Record<string, unknown>> | null;
|
||||||
|
if (emailsResponse.ok && Array.isArray(list)) {
|
||||||
|
const primaryVerified = list.find((e) => Boolean(e?.primary) && Boolean(e?.verified) && typeof e?.email === 'string');
|
||||||
|
const anyVerified = list.find((e) => Boolean(e?.verified) && typeof e?.email === 'string');
|
||||||
|
email = (primaryVerified?.email as string | undefined) || (anyVerified?.email as string | undefined);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: String(payload.login || ''),
|
||||||
|
id: typeof payload.id === 'number' ? payload.id : undefined,
|
||||||
|
avatarUrl: typeof payload.avatar_url === 'string' ? payload.avatar_url : undefined,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const API_BASE = 'https://api.github.com';
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
type GitHubRepoRef = { owner: string; repo: string; url: string };
|
||||||
|
|
||||||
|
type GitHubChecksSummary = {
|
||||||
|
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failure: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubPullRequest = {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
state: 'open' | 'closed' | 'merged';
|
||||||
|
draft: boolean;
|
||||||
|
base: string;
|
||||||
|
head: string;
|
||||||
|
headSha?: string;
|
||||||
|
mergeable?: boolean | null;
|
||||||
|
mergeableState?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubPullRequestStatus = {
|
||||||
|
connected: boolean;
|
||||||
|
repo?: GitHubRepoRef | null;
|
||||||
|
branch?: string;
|
||||||
|
pr?: GitHubPullRequest | null;
|
||||||
|
checks?: GitHubChecksSummary | null;
|
||||||
|
canMerge?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubPullRequestCreateInput = {
|
||||||
|
directory: string;
|
||||||
|
title: string;
|
||||||
|
head: string;
|
||||||
|
base: string;
|
||||||
|
body?: string;
|
||||||
|
draft?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubPullRequestMergeInput = {
|
||||||
|
directory: string;
|
||||||
|
number: number;
|
||||||
|
method: 'merge' | 'squash' | 'rebase';
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubPullRequestMergeResult = { merged: boolean; message?: string };
|
||||||
|
|
||||||
|
const parseGitHubRemoteUrl = (raw: string): GitHubRepoRef | null => {
|
||||||
|
const value = raw.trim();
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
if (value.startsWith('git@github.com:')) {
|
||||||
|
const rest = value.slice('git@github.com:'.length);
|
||||||
|
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||||
|
const [owner, repo] = cleaned.split('/');
|
||||||
|
if (!owner || !repo) return null;
|
||||||
|
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('ssh://git@github.com/')) {
|
||||||
|
const rest = value.slice('ssh://git@github.com/'.length);
|
||||||
|
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||||
|
const [owner, repo] = cleaned.split('/');
|
||||||
|
if (!owner || !repo) return null;
|
||||||
|
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.hostname !== 'github.com') return null;
|
||||||
|
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
|
||||||
|
const [owner, repo] = cleaned.split('/');
|
||||||
|
if (!owner || !repo) return null;
|
||||||
|
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOriginRemoteUrl = async (directory: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('git', ['-C', directory, 'remote', 'get-url', 'origin']);
|
||||||
|
const url = String(stdout || '').trim();
|
||||||
|
return url || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveRepoFromDirectory = async (directory: string): Promise<GitHubRepoRef | null> => {
|
||||||
|
const remote = await getOriginRemoteUrl(directory);
|
||||||
|
if (!remote) return null;
|
||||||
|
return parseGitHubRemoteUrl(remote);
|
||||||
|
};
|
||||||
|
|
||||||
|
const githubFetch = async (
|
||||||
|
url: string,
|
||||||
|
accessToken: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'User-Agent': 'OpenChamber',
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||||
|
return (await response.json().catch(() => null)) as T | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||||
|
|
||||||
|
export const getPullRequestStatus = async (
|
||||||
|
accessToken: string,
|
||||||
|
userLogin: string | null,
|
||||||
|
directory: string,
|
||||||
|
branch: string,
|
||||||
|
): Promise<GitHubPullRequestStatus> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||||
|
listUrl.searchParams.set('state', 'open');
|
||||||
|
listUrl.searchParams.set('head', `${repo.owner}:${branch}`);
|
||||||
|
listUrl.searchParams.set('per_page', '10');
|
||||||
|
|
||||||
|
const listResp = await githubFetch(listUrl.toString(), accessToken);
|
||||||
|
if (listResp.status === 401) {
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
const list = await jsonOrNull<Array<{ number: number }>>(listResp);
|
||||||
|
if (!listResp.ok || !Array.isArray(list) || list.length === 0) {
|
||||||
|
return { connected: true, repo, branch, pr: null, checks: null, canMerge: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = list[0].number;
|
||||||
|
const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken);
|
||||||
|
if (prResp.status === 401) {
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
const prJson = await jsonOrNull<JsonRecord>(prResp);
|
||||||
|
if (!prResp.ok || !prJson) {
|
||||||
|
throw new Error('Failed to load PR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = Boolean(prJson.merged);
|
||||||
|
const prState = readString(prJson.state);
|
||||||
|
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
|
||||||
|
const pr: GitHubPullRequest = {
|
||||||
|
number: typeof prJson.number === 'number' ? prJson.number : 0,
|
||||||
|
title: readString(prJson.title) || '',
|
||||||
|
url: readString(prJson.html_url) || '',
|
||||||
|
state,
|
||||||
|
draft: Boolean(prJson.draft),
|
||||||
|
base: readString((prJson.base as JsonRecord | undefined)?.ref) || '',
|
||||||
|
head: readString((prJson.head as JsonRecord | undefined)?.ref) || '',
|
||||||
|
headSha: readString((prJson.head as JsonRecord | undefined)?.sha) || undefined,
|
||||||
|
mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null,
|
||||||
|
mergeableState: readString(prJson.mergeable_state) || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
let checks: GitHubChecksSummary | null = null;
|
||||||
|
if (pr.headSha) {
|
||||||
|
const statusResp = await githubFetch(
|
||||||
|
`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/status`,
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
const statusJson = await jsonOrNull<JsonRecord>(statusResp);
|
||||||
|
if (statusResp.ok && statusJson) {
|
||||||
|
const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : [];
|
||||||
|
const counts = { success: 0, failure: 0, pending: 0 };
|
||||||
|
statuses.forEach((s) => {
|
||||||
|
const st = readString((s as JsonRecord | null)?.state);
|
||||||
|
if (st === 'success') counts.success += 1;
|
||||||
|
else if (st === 'failure' || st === 'error') counts.failure += 1;
|
||||||
|
else if (st === 'pending') counts.pending += 1;
|
||||||
|
});
|
||||||
|
const total = counts.success + counts.failure + counts.pending;
|
||||||
|
const state2 = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||||
|
checks = { state: state2, total, ...counts };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let canMerge = false;
|
||||||
|
if (userLogin) {
|
||||||
|
const permResp = await githubFetch(
|
||||||
|
`${API_BASE}/repos/${repo.owner}/${repo.repo}/collaborators/${encodeURIComponent(userLogin)}/permission`,
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
const permJson = await jsonOrNull<{ permission?: string }>(permResp);
|
||||||
|
const perm = typeof permJson?.permission === 'string' ? permJson.permission : '';
|
||||||
|
canMerge = perm === 'admin' || perm === 'maintain' || perm === 'write';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connected: true, repo, branch, pr, checks, canMerge };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPullRequest = async (
|
||||||
|
accessToken: string,
|
||||||
|
directory: string,
|
||||||
|
payload: GitHubPullRequestCreateInput,
|
||||||
|
): Promise<GitHubPullRequest> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`, accessToken, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: payload.title,
|
||||||
|
head: payload.head,
|
||||||
|
base: payload.base,
|
||||||
|
...(typeof payload.body === 'string' ? { body: payload.body } : {}),
|
||||||
|
...(typeof payload.draft === 'boolean' ? { draft: payload.draft } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await jsonOrNull<JsonRecord>(resp);
|
||||||
|
if (!resp.ok || !json) {
|
||||||
|
throw new Error('Failed to create PR');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
number: typeof json.number === 'number' ? json.number : 0,
|
||||||
|
title: readString(json.title) || '',
|
||||||
|
url: readString(json.html_url) || '',
|
||||||
|
state: readString(json.state) === 'closed' ? 'closed' : 'open',
|
||||||
|
draft: Boolean(json.draft),
|
||||||
|
base: readString((json.base as JsonRecord | undefined)?.ref) || payload.base,
|
||||||
|
head: readString((json.head as JsonRecord | undefined)?.ref) || payload.head,
|
||||||
|
headSha: readString((json.head as JsonRecord | undefined)?.sha) || undefined,
|
||||||
|
mergeable: typeof json.mergeable === 'boolean' ? json.mergeable : null,
|
||||||
|
mergeableState: readString(json.mergeable_state) || undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergePullRequest = async (
|
||||||
|
accessToken: string,
|
||||||
|
directory: string,
|
||||||
|
payload: GitHubPullRequestMergeInput,
|
||||||
|
): Promise<GitHubPullRequestMergeResult> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${payload.number}/merge`, accessToken, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ merge_method: payload.method }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 403) {
|
||||||
|
throw new Error('Not authorized to merge this PR');
|
||||||
|
}
|
||||||
|
if (resp.status === 405 || resp.status === 409) {
|
||||||
|
return { merged: false, message: 'PR not mergeable' };
|
||||||
|
}
|
||||||
|
const json = await jsonOrNull<JsonRecord>(resp);
|
||||||
|
if (!resp.ok || !json) {
|
||||||
|
throw new Error('Failed to merge PR');
|
||||||
|
}
|
||||||
|
return { merged: Boolean(json.merged), message: readString(json.message) || undefined };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markPullRequestReady = async (
|
||||||
|
accessToken: string,
|
||||||
|
directory: string,
|
||||||
|
number: number,
|
||||||
|
): Promise<{ ready: boolean }> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken);
|
||||||
|
if (prResp.status === 401) {
|
||||||
|
return { ready: false };
|
||||||
|
}
|
||||||
|
const prJson = await jsonOrNull<JsonRecord>(prResp);
|
||||||
|
const nodeId = typeof prJson?.node_id === 'string' ? prJson.node_id : '';
|
||||||
|
const isDraft = Boolean((prJson as Record<string, unknown> | null)?.draft);
|
||||||
|
if (!prResp.ok || !nodeId) {
|
||||||
|
throw new Error('Failed to resolve PR node id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDraft) {
|
||||||
|
return { ready: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await githubFetch(`${API_BASE}/graphql`, accessToken, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query:
|
||||||
|
'mutation($pullRequestId: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { pullRequest { id isDraft } } }',
|
||||||
|
variables: { pullRequestId: nodeId },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 403) {
|
||||||
|
throw new Error('Not authorized to mark PR ready');
|
||||||
|
}
|
||||||
|
if (resp.status === 401) {
|
||||||
|
const error = new Error('unauthorized');
|
||||||
|
(error as unknown as { status?: number }).status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const json = await jsonOrNull<JsonRecord>(resp);
|
||||||
|
if (!resp.ok || !json) {
|
||||||
|
throw new Error('Failed to mark PR ready');
|
||||||
|
}
|
||||||
|
if (json.errors) {
|
||||||
|
throw new Error('GitHub GraphQL error');
|
||||||
|
}
|
||||||
|
return { ready: true };
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
GitDeleteBranchPayload,
|
GitDeleteBranchPayload,
|
||||||
GitDeleteRemoteBranchPayload,
|
GitDeleteRemoteBranchPayload,
|
||||||
GeneratedCommitMessage,
|
GeneratedCommitMessage,
|
||||||
|
GeneratedPullRequestDescription,
|
||||||
GitWorktreeInfo,
|
GitWorktreeInfo,
|
||||||
GitAddWorktreePayload,
|
GitAddWorktreePayload,
|
||||||
GitRemoveWorktreePayload,
|
GitRemoveWorktreePayload,
|
||||||
@@ -96,6 +97,17 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
generatePullRequestDescription: async (
|
||||||
|
directory: string,
|
||||||
|
payload: { base: string; head: string }
|
||||||
|
): Promise<GeneratedPullRequestDescription> => {
|
||||||
|
return sendBridgeMessage<GeneratedPullRequestDescription>('api:git/pr-description', {
|
||||||
|
directory,
|
||||||
|
base: payload.base,
|
||||||
|
head: payload.head,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
listGitWorktrees: async (directory: string): Promise<GitWorktreeInfo[]> => {
|
listGitWorktrees: async (directory: string): Promise<GitWorktreeInfo[]> => {
|
||||||
return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' });
|
return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type {
|
||||||
|
GitHubAPI,
|
||||||
|
GitHubAuthStatus,
|
||||||
|
GitHubPullRequest,
|
||||||
|
GitHubPullRequestCreateInput,
|
||||||
|
GitHubPullRequestMergeInput,
|
||||||
|
GitHubPullRequestMergeResult,
|
||||||
|
GitHubPullRequestReadyInput,
|
||||||
|
GitHubPullRequestReadyResult,
|
||||||
|
GitHubPullRequestStatus,
|
||||||
|
GitHubDeviceFlowComplete,
|
||||||
|
GitHubDeviceFlowStart,
|
||||||
|
GitHubUserSummary,
|
||||||
|
} from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
|
import { sendBridgeMessage } from './bridge';
|
||||||
|
|
||||||
|
export const createVSCodeGitHubAPI = (): GitHubAPI => ({
|
||||||
|
authStatus: async () => sendBridgeMessage<GitHubAuthStatus>('api:github/auth:status'),
|
||||||
|
authStart: async () => sendBridgeMessage<GitHubDeviceFlowStart>('api:github/auth:start'),
|
||||||
|
authComplete: async (deviceCode: string) =>
|
||||||
|
sendBridgeMessage<GitHubDeviceFlowComplete>('api:github/auth:complete', { deviceCode }),
|
||||||
|
authDisconnect: async () => sendBridgeMessage<{ removed: boolean }>('api:github/auth:disconnect'),
|
||||||
|
me: async () => sendBridgeMessage<GitHubUserSummary>('api:github/me'),
|
||||||
|
|
||||||
|
prStatus: async (directory: string, branch: string) =>
|
||||||
|
sendBridgeMessage<GitHubPullRequestStatus>('api:github/pr:status', { directory, branch }),
|
||||||
|
prCreate: async (payload: GitHubPullRequestCreateInput) =>
|
||||||
|
sendBridgeMessage<GitHubPullRequest>('api:github/pr:create', payload),
|
||||||
|
prMerge: async (payload: GitHubPullRequestMergeInput) =>
|
||||||
|
sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload),
|
||||||
|
prReady: async (payload: GitHubPullRequestReadyInput) =>
|
||||||
|
sendBridgeMessage<GitHubPullRequestReadyResult>('api:github/pr:ready', payload),
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { createVSCodeToolsAPI } from './tools';
|
|||||||
import { createVSCodeEditorAPI } from './editor';
|
import { createVSCodeEditorAPI } from './editor';
|
||||||
import { createVSCodeGitAPI } from './git';
|
import { createVSCodeGitAPI } from './git';
|
||||||
import { createVSCodeActionsAPI } from './vscode';
|
import { createVSCodeActionsAPI } from './vscode';
|
||||||
|
import { createVSCodeGitHubAPI } from './github';
|
||||||
|
|
||||||
// Stub APIs return sensible defaults instead of throwing
|
// Stub APIs return sensible defaults instead of throwing
|
||||||
const createStubTerminalAPI = (): TerminalAPI => ({
|
const createStubTerminalAPI = (): TerminalAPI => ({
|
||||||
@@ -29,6 +30,7 @@ export const createVSCodeAPIs = (): RuntimeAPIs => ({
|
|||||||
settings: createVSCodeSettingsAPI(),
|
settings: createVSCodeSettingsAPI(),
|
||||||
permissions: createVSCodePermissionsAPI(),
|
permissions: createVSCodePermissionsAPI(),
|
||||||
notifications: createStubNotificationsAPI(),
|
notifications: createStubNotificationsAPI(),
|
||||||
|
github: createVSCodeGitHubAPI(),
|
||||||
tools: createVSCodeToolsAPI(),
|
tools: createVSCodeToolsAPI(),
|
||||||
editor: createVSCodeEditorAPI(),
|
editor: createVSCodeEditorAPI(),
|
||||||
vscode: createVSCodeActionsAPI(),
|
vscode: createVSCodeActionsAPI(),
|
||||||
|
|||||||
@@ -721,6 +721,18 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
|
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
|
||||||
result.markdownDisplayMode = candidate.markdownDisplayMode;
|
result.markdownDisplayMode = candidate.markdownDisplayMode;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.githubClientId === 'string') {
|
||||||
|
const trimmed = candidate.githubClientId.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
result.githubClientId = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof candidate.githubScopes === 'string') {
|
||||||
|
const trimmed = candidate.githubScopes.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
result.githubScopes = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
result.showReasoningTraces = candidate.showReasoningTraces;
|
||||||
}
|
}
|
||||||
@@ -3839,6 +3851,471 @@ async function main(options = {}) {
|
|||||||
return authLibrary;
|
return authLibrary;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ================= GitHub OAuth (Device Flow) =================
|
||||||
|
|
||||||
|
// Note: scopes may be overridden via OPENCHAMBER_GITHUB_SCOPES or settings.json (see github-auth.js).
|
||||||
|
|
||||||
|
let githubLibraries = null;
|
||||||
|
const getGitHubLibraries = async () => {
|
||||||
|
if (!githubLibraries) {
|
||||||
|
const [auth, device, octokit] = await Promise.all([
|
||||||
|
import('./lib/github-auth.js'),
|
||||||
|
import('./lib/github-device-flow.js'),
|
||||||
|
import('./lib/github-octokit.js'),
|
||||||
|
]);
|
||||||
|
githubLibraries = { ...auth, ...device, ...octokit };
|
||||||
|
}
|
||||||
|
return githubLibraries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGitHubUserSummary = async (octokit) => {
|
||||||
|
const me = await octokit.rest.users.getAuthenticated();
|
||||||
|
|
||||||
|
let email = typeof me.data.email === 'string' ? me.data.email : null;
|
||||||
|
if (!email) {
|
||||||
|
try {
|
||||||
|
const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 });
|
||||||
|
const list = Array.isArray(emails?.data) ? emails.data : [];
|
||||||
|
const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string');
|
||||||
|
const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string');
|
||||||
|
email = primaryVerified?.email || anyVerified?.email || null;
|
||||||
|
} catch {
|
||||||
|
// ignore (scope might be missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: me.data.login,
|
||||||
|
id: me.data.id,
|
||||||
|
avatarUrl: me.data.avatar_url,
|
||||||
|
name: typeof me.data.name === 'string' ? me.data.name : null,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/github/auth/status', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
|
||||||
|
const auth = getGitHubAuth();
|
||||||
|
if (!auth?.accessToken) {
|
||||||
|
return res.json({ connected: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const octokit = getOctokitOrNull();
|
||||||
|
if (!octokit) {
|
||||||
|
return res.json({ connected: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = null;
|
||||||
|
try {
|
||||||
|
user = await getGitHubUserSummary(octokit);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 401) {
|
||||||
|
clearGitHubAuth();
|
||||||
|
return res.json({ connected: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = auth.user;
|
||||||
|
const mergedUser = user || fallback;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
connected: true,
|
||||||
|
user: mergedUser,
|
||||||
|
scope: auth.scope,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get GitHub auth status:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/github/auth/start', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
|
||||||
|
const clientId = getGitHubClientId();
|
||||||
|
if (!clientId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = getGitHubScopes();
|
||||||
|
|
||||||
|
const payload = await startDeviceFlow({
|
||||||
|
clientId,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
deviceCode: payload.device_code,
|
||||||
|
userCode: payload.user_code,
|
||||||
|
verificationUri: payload.verification_uri,
|
||||||
|
verificationUriComplete: payload.verification_uri_complete,
|
||||||
|
expiresIn: payload.expires_in,
|
||||||
|
interval: payload.interval,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start GitHub device flow:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/github/auth/complete', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth } = await getGitHubLibraries();
|
||||||
|
const clientId = getGitHubClientId();
|
||||||
|
if (!clientId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceCode = typeof req.body?.deviceCode === 'string'
|
||||||
|
? req.body.deviceCode
|
||||||
|
: (typeof req.body?.device_code === 'string' ? req.body.device_code : '');
|
||||||
|
|
||||||
|
if (!deviceCode) {
|
||||||
|
return res.status(400).json({ error: 'deviceCode is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await exchangeDeviceCode({ clientId, deviceCode });
|
||||||
|
|
||||||
|
if (payload?.error) {
|
||||||
|
return res.json({
|
||||||
|
connected: false,
|
||||||
|
status: payload.error,
|
||||||
|
error: payload.error_description || payload.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = payload?.access_token;
|
||||||
|
if (!accessToken) {
|
||||||
|
return res.status(500).json({ error: 'Missing access_token from GitHub' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Octokit } = await import('@octokit/rest');
|
||||||
|
const octokit = new Octokit({ auth: accessToken });
|
||||||
|
const user = await getGitHubUserSummary(octokit);
|
||||||
|
|
||||||
|
setGitHubAuth({
|
||||||
|
accessToken,
|
||||||
|
scope: typeof payload.scope === 'string' ? payload.scope : '',
|
||||||
|
tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer',
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
connected: true,
|
||||||
|
user,
|
||||||
|
scope: typeof payload.scope === 'string' ? payload.scope : '',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to complete GitHub device flow:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/github/auth', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||||
|
const removed = clearGitHubAuth();
|
||||||
|
return res.json({ success: true, removed });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect GitHub:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/github/me', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
|
||||||
|
const octokit = getOctokitOrNull();
|
||||||
|
if (!octokit) {
|
||||||
|
return res.status(401).json({ error: 'GitHub not connected' });
|
||||||
|
}
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = await getGitHubUserSummary(octokit);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 401) {
|
||||||
|
clearGitHubAuth();
|
||||||
|
return res.status(401).json({ error: 'GitHub token expired or revoked' });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch GitHub user:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================= GitHub PR APIs =================
|
||||||
|
|
||||||
|
app.get('/api/github/pr/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||||
|
const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
|
||||||
|
if (!directory || !branch) {
|
||||||
|
return res.status(400).json({ error: 'directory and branch are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries();
|
||||||
|
const octokit = getOctokitOrNull();
|
||||||
|
if (!octokit) {
|
||||||
|
return res.json({ connected: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
|
||||||
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find PR for this branch (same-repo assumption)
|
||||||
|
const list = await octokit.rest.pulls.list({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
state: 'open',
|
||||||
|
head: `${repo.owner}:${branch}`,
|
||||||
|
per_page: 10,
|
||||||
|
});
|
||||||
|
const first = Array.isArray(list?.data) ? list.data[0] : null;
|
||||||
|
if (!first) {
|
||||||
|
return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with mergeability fields
|
||||||
|
const prFull = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: first.number });
|
||||||
|
const prData = prFull?.data;
|
||||||
|
if (!prData) {
|
||||||
|
return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks summary (combined status)
|
||||||
|
let checks = null;
|
||||||
|
try {
|
||||||
|
const combined = await octokit.rest.repos.getCombinedStatusForRef({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
ref: prData.head?.sha,
|
||||||
|
});
|
||||||
|
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
|
||||||
|
const counts = { success: 0, failure: 0, pending: 0 };
|
||||||
|
statuses.forEach((s) => {
|
||||||
|
if (s.state === 'success') counts.success += 1;
|
||||||
|
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
||||||
|
else if (s.state === 'pending') counts.pending += 1;
|
||||||
|
});
|
||||||
|
const total = counts.success + counts.failure + counts.pending;
|
||||||
|
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||||
|
checks = { state, total, ...counts };
|
||||||
|
} catch {
|
||||||
|
checks = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission check (best-effort)
|
||||||
|
let canMerge = false;
|
||||||
|
try {
|
||||||
|
const auth = getGitHubAuth();
|
||||||
|
const username = auth?.user?.login;
|
||||||
|
if (username) {
|
||||||
|
const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
const level = perm?.data?.permission;
|
||||||
|
canMerge = level === 'admin' || level === 'maintain' || level === 'write';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
canMerge = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
connected: true,
|
||||||
|
repo,
|
||||||
|
branch,
|
||||||
|
pr: {
|
||||||
|
number: prData.number,
|
||||||
|
title: prData.title,
|
||||||
|
url: prData.html_url,
|
||||||
|
state: mergedState,
|
||||||
|
draft: Boolean(prData.draft),
|
||||||
|
base: prData.base?.ref,
|
||||||
|
head: prData.head?.ref,
|
||||||
|
headSha: prData.head?.sha,
|
||||||
|
mergeable: prData.mergeable,
|
||||||
|
mergeableState: prData.mergeable_state,
|
||||||
|
},
|
||||||
|
checks,
|
||||||
|
canMerge,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 401) {
|
||||||
|
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||||
|
clearGitHubAuth();
|
||||||
|
return res.json({ connected: false });
|
||||||
|
}
|
||||||
|
console.error('Failed to load GitHub PR status:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/github/pr/create', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||||
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
||||||
|
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
|
||||||
|
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
|
||||||
|
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
|
||||||
|
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
|
||||||
|
if (!directory || !title || !head || !base) {
|
||||||
|
return res.status(400).json({ error: 'directory, title, head, base are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||||
|
const octokit = getOctokitOrNull();
|
||||||
|
if (!octokit) {
|
||||||
|
return res.status(401).json({ error: 'GitHub not connected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
|
||||||
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await octokit.rest.pulls.create({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
title,
|
||||||
|
head,
|
||||||
|
base,
|
||||||
|
...(typeof body === 'string' ? { body } : {}),
|
||||||
|
...(typeof draft === 'boolean' ? { draft } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pr = created?.data;
|
||||||
|
if (!pr) {
|
||||||
|
return res.status(500).json({ error: 'Failed to create PR' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
url: pr.html_url,
|
||||||
|
state: pr.state === 'closed' ? 'closed' : 'open',
|
||||||
|
draft: Boolean(pr.draft),
|
||||||
|
base: pr.base?.ref,
|
||||||
|
head: pr.head?.ref,
|
||||||
|
headSha: pr.head?.sha,
|
||||||
|
mergeable: pr.mergeable,
|
||||||
|
mergeableState: pr.mergeable_state,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create GitHub PR:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/github/pr/merge', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||||
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||||
|
const method = typeof req.body?.method === 'string' ? req.body.method : 'merge';
|
||||||
|
if (!directory || !number) {
|
||||||
|
return res.status(400).json({ error: 'directory and number are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||||
|
const octokit = getOctokitOrNull();
|
||||||
|
if (!octokit) {
|
||||||
|
return res.status(401).json({ error: 'GitHub not connected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
|
||||||
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await octokit.rest.pulls.merge({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
pull_number: number,
|
||||||
|
merge_method: method,
|
||||||
|
});
|
||||||
|
return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 403) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized to merge this PR' });
|
||||||
|
}
|
||||||
|
if (error?.status === 405 || error?.status === 409) {
|
||||||
|
return res.json({ merged: false, message: error?.message || 'PR not mergeable' });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to merge GitHub PR:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/github/pr/ready', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||||
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||||
|
if (!directory || !number) {
|
||||||
|
return res.status(400).json({ error: 'directory and number are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||||
|
const octokit = getOctokitOrNull();
|
||||||
|
if (!octokit) {
|
||||||
|
return res.status(401).json({ error: 'GitHub not connected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
|
||||||
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
|
||||||
|
const nodeId = pr?.data?.node_id;
|
||||||
|
if (!nodeId) {
|
||||||
|
return res.status(500).json({ error: 'Failed to resolve PR node id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pr?.data?.draft === false) {
|
||||||
|
return res.json({ ready: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await octokit.graphql(
|
||||||
|
`mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`,
|
||||||
|
{ pullRequestId: nodeId }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 403) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized to mark PR ready' });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ ready: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark PR ready:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to mark PR ready' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/provider/:providerId/source', async (req, res) => {
|
app.get('/api/provider/:providerId/source', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { providerId } = req.params;
|
const { providerId } = req.params;
|
||||||
@@ -4313,6 +4790,97 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/git/pr-description', async (req, res) => {
|
||||||
|
const { getRangeDiff, getRangeFiles } = await getGitLibraries();
|
||||||
|
try {
|
||||||
|
const directory = req.query.directory;
|
||||||
|
if (!directory || typeof directory !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'directory parameter is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
|
||||||
|
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
|
||||||
|
if (!base || !head) {
|
||||||
|
return res.status(400).json({ error: 'base and head are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesToDiff = await getRangeFiles(directory, { base, head });
|
||||||
|
|
||||||
|
const diffs = [];
|
||||||
|
for (const filePath of filesToDiff) {
|
||||||
|
const diff = await getRangeDiff(directory, { base, head, path: filePath, contextLines: 3 }).catch(() => '');
|
||||||
|
if (diff && diff.trim().length > 0) {
|
||||||
|
diffs.push({ path: filePath, diff });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (diffs.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No diffs available for selected files' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
|
||||||
|
|
||||||
|
const prompt = `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:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}\n\nDiff summary:\n${diffSummaries}`;
|
||||||
|
|
||||||
|
const model = 'gpt-5-nano';
|
||||||
|
|
||||||
|
const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch('https://opencode.ai/zen/v1/responses', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
input: [{ role: 'user', content: prompt }],
|
||||||
|
max_output_tokens: 1200,
|
||||||
|
stream: false,
|
||||||
|
reasoning: { effort: 'low' },
|
||||||
|
}),
|
||||||
|
signal: completionTimeout.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
completionTimeout.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.json().catch(() => ({}));
|
||||||
|
console.error('PR description generation failed:', errorBody);
|
||||||
|
return res.status(502).json({ error: 'Failed to generate PR description' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const raw = data?.output?.find((item) => item?.type === 'message')?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return res.status(502).json({ error: 'No PR description returned by generator' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedJson = stripJsonMarkdownWrapper(raw);
|
||||||
|
const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw);
|
||||||
|
const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => {
|
||||||
|
return candidate && array.indexOf(candidate) === index;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(candidate);
|
||||||
|
const title = typeof parsed?.title === 'string' ? parsed.title : '';
|
||||||
|
const body = typeof parsed?.body === 'string' ? parsed.body : '';
|
||||||
|
return res.json({ title, body });
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('PR description generation returned non-JSON body:', parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ title: '', body: raw });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate PR description:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to generate PR description' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/git/pull', async (req, res) => {
|
app.post('/api/git/pull', async (req, res) => {
|
||||||
const { pull } = await getGitLibraries();
|
const { pull } = await getGitLibraries();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -432,6 +432,40 @@ export async function getDiff(directory, { path, staged = false, contextLines =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) {
|
||||||
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||||
|
const headRef = typeof head === 'string' ? head.trim() : '';
|
||||||
|
if (!baseRef || !headRef) {
|
||||||
|
throw new Error('base and head are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ['diff', '--no-color'];
|
||||||
|
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||||
|
args.push(`-U${Math.max(0, contextLines)}`);
|
||||||
|
}
|
||||||
|
args.push(`${baseRef}...${headRef}`);
|
||||||
|
if (path) {
|
||||||
|
args.push('--', path);
|
||||||
|
}
|
||||||
|
const diff = await git.raw(args);
|
||||||
|
return diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRangeFiles(directory, { base, head } = {}) {
|
||||||
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||||
|
const headRef = typeof head === 'string' ? head.trim() : '';
|
||||||
|
if (!baseRef || !headRef) {
|
||||||
|
throw new Error('base and head are required');
|
||||||
|
}
|
||||||
|
const raw = await git.raw(['diff', '--name-only', `${baseRef}...${headRef}`]);
|
||||||
|
return String(raw || '')
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
|
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
|
||||||
|
|
||||||
function isImageFile(filePath) {
|
function isImageFile(filePath) {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||||
|
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||||
|
: path.join(os.homedir(), '.config', 'openchamber');
|
||||||
|
|
||||||
|
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
|
||||||
|
const STORAGE_FILE = path.join(STORAGE_DIR, 'github-auth.json');
|
||||||
|
const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||||
|
|
||||||
|
const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
|
||||||
|
const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
|
||||||
|
|
||||||
|
function ensureStorageDir() {
|
||||||
|
if (!fs.existsSync(STORAGE_DIR)) {
|
||||||
|
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonFile() {
|
||||||
|
ensureStorageDir();
|
||||||
|
if (!fs.existsSync(STORAGE_FILE)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read GitHub auth file:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonFile(payload) {
|
||||||
|
ensureStorageDir();
|
||||||
|
fs.writeFileSync(STORAGE_FILE, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
try {
|
||||||
|
fs.chmodSync(STORAGE_FILE, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitHubAuth() {
|
||||||
|
const data = readJsonFile();
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const accessToken = typeof data.accessToken === 'string' ? data.accessToken : '';
|
||||||
|
if (!accessToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
scope: typeof data.scope === 'string' ? data.scope : '',
|
||||||
|
tokenType: typeof data.tokenType === 'string' ? data.tokenType : 'bearer',
|
||||||
|
createdAt: typeof data.createdAt === 'number' ? data.createdAt : null,
|
||||||
|
user: data.user && typeof data.user === 'object'
|
||||||
|
? {
|
||||||
|
login: typeof data.user.login === 'string' ? data.user.login : null,
|
||||||
|
avatarUrl: typeof data.user.avatarUrl === 'string' ? data.user.avatarUrl : null,
|
||||||
|
id: typeof data.user.id === 'number' ? data.user.id : null,
|
||||||
|
name: typeof data.user.name === 'string' ? data.user.name : null,
|
||||||
|
email: typeof data.user.email === 'string' ? data.user.email : null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGitHubAuth({ accessToken, scope, tokenType, user }) {
|
||||||
|
if (!accessToken || typeof accessToken !== 'string') {
|
||||||
|
throw new Error('accessToken is required');
|
||||||
|
}
|
||||||
|
writeJsonFile({
|
||||||
|
accessToken,
|
||||||
|
scope: typeof scope === 'string' ? scope : '',
|
||||||
|
tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
user: user && typeof user === 'object'
|
||||||
|
? {
|
||||||
|
login: typeof user.login === 'string' ? user.login : undefined,
|
||||||
|
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
|
||||||
|
id: typeof user.id === 'number' ? user.id : undefined,
|
||||||
|
name: typeof user.name === 'string' ? user.name : undefined,
|
||||||
|
email: typeof user.email === 'string' ? user.email : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGitHubAuth() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(STORAGE_FILE)) {
|
||||||
|
fs.unlinkSync(STORAGE_FILE);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear GitHub auth file:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitHubClientId() {
|
||||||
|
const raw = process.env.OPENCHAMBER_GITHUB_CLIENT_ID;
|
||||||
|
const clientId = typeof raw === 'string' ? raw.trim() : '';
|
||||||
|
if (clientId) return clientId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(SETTINGS_FILE)) {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
||||||
|
const stored = typeof parsed?.githubClientId === 'string' ? parsed.githubClientId.trim() : '';
|
||||||
|
if (stored) return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_GITHUB_CLIENT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitHubScopes() {
|
||||||
|
const raw = process.env.OPENCHAMBER_GITHUB_SCOPES;
|
||||||
|
const fromEnv = typeof raw === 'string' ? raw.trim() : '';
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(SETTINGS_FILE)) {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
||||||
|
const stored = typeof parsed?.githubScopes === 'string' ? parsed.githubScopes.trim() : '';
|
||||||
|
if (stored) return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_GITHUB_SCOPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GITHUB_AUTH_FILE = STORAGE_FILE;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
||||||
|
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
||||||
|
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
|
||||||
|
const encodeForm = (params) => {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value == null) continue;
|
||||||
|
body.set(key, String(value));
|
||||||
|
}
|
||||||
|
return body.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function postForm(url, params) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: encodeForm(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = payload?.error_description || payload?.error || response.statusText;
|
||||||
|
const error = new Error(message || 'GitHub request failed');
|
||||||
|
error.status = response.status;
|
||||||
|
error.payload = payload;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startDeviceFlow({ clientId, scope }) {
|
||||||
|
return postForm(DEVICE_CODE_URL, {
|
||||||
|
client_id: clientId,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeDeviceCode({ clientId, deviceCode }) {
|
||||||
|
// GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
|
||||||
|
const payload = await postForm(ACCESS_TOKEN_URL, {
|
||||||
|
client_id: clientId,
|
||||||
|
device_code: deviceCode,
|
||||||
|
grant_type: DEVICE_GRANT_TYPE,
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Octokit } from '@octokit/rest';
|
||||||
|
import { getGitHubAuth } from './github-auth.js';
|
||||||
|
|
||||||
|
export function getOctokitOrNull() {
|
||||||
|
const auth = getGitHubAuth();
|
||||||
|
if (!auth?.accessToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Octokit({ auth: auth.accessToken });
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { getRemoteUrl } from './git-service.js';
|
||||||
|
|
||||||
|
export const parseGitHubRemoteUrl = (raw) => {
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = raw.trim();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// git@github.com:OWNER/REPO.git
|
||||||
|
if (value.startsWith('git@github.com:')) {
|
||||||
|
const rest = value.slice('git@github.com:'.length);
|
||||||
|
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||||
|
const [owner, repo] = cleaned.split('/');
|
||||||
|
if (!owner || !repo) return null;
|
||||||
|
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssh://git@github.com/OWNER/REPO.git
|
||||||
|
if (value.startsWith('ssh://git@github.com/')) {
|
||||||
|
const rest = value.slice('ssh://git@github.com/'.length);
|
||||||
|
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||||
|
const [owner, repo] = cleaned.split('/');
|
||||||
|
if (!owner || !repo) return null;
|
||||||
|
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://github.com/OWNER/REPO(.git)
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.hostname !== 'github.com') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
|
||||||
|
const [owner, repo] = cleaned.split('/');
|
||||||
|
if (!owner || !repo) return null;
|
||||||
|
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveGitHubRepoFromDirectory(directory) {
|
||||||
|
const remoteUrl = await getRemoteUrl(directory).catch(() => null);
|
||||||
|
if (!remoteUrl) {
|
||||||
|
return { repo: null, remoteUrl: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
repo: parseGitHubRemoteUrl(remoteUrl),
|
||||||
|
remoteUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const createWebGitAPI = (): GitAPI => ({
|
|||||||
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
|
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
|
||||||
deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'],
|
deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'],
|
||||||
generateCommitMessage: gitApiHttp.generateCommitMessage,
|
generateCommitMessage: gitApiHttp.generateCommitMessage,
|
||||||
|
generatePullRequestDescription: gitApiHttp.generatePullRequestDescription,
|
||||||
listGitWorktrees: gitApiHttp.listGitWorktrees,
|
listGitWorktrees: gitApiHttp.listGitWorktrees,
|
||||||
addGitWorktree: gitApiHttp.addGitWorktree as GitAPI['addGitWorktree'],
|
addGitWorktree: gitApiHttp.addGitWorktree as GitAPI['addGitWorktree'],
|
||||||
removeGitWorktree: gitApiHttp.removeGitWorktree as GitAPI['removeGitWorktree'],
|
removeGitWorktree: gitApiHttp.removeGitWorktree as GitAPI['removeGitWorktree'],
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type {
|
||||||
|
GitHubAPI,
|
||||||
|
GitHubAuthStatus,
|
||||||
|
GitHubPullRequest,
|
||||||
|
GitHubPullRequestCreateInput,
|
||||||
|
GitHubPullRequestMergeInput,
|
||||||
|
GitHubPullRequestMergeResult,
|
||||||
|
GitHubPullRequestReadyInput,
|
||||||
|
GitHubPullRequestReadyResult,
|
||||||
|
GitHubPullRequestStatus,
|
||||||
|
GitHubDeviceFlowComplete,
|
||||||
|
GitHubDeviceFlowStart,
|
||||||
|
GitHubUserSummary,
|
||||||
|
} from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
|
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||||
|
return (await response.json().catch(() => null)) as T | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createWebGitHubAPI = (): GitHubAPI => ({
|
||||||
|
async authStatus(): Promise<GitHubAuthStatus> {
|
||||||
|
const response = await fetch('/api/github/auth/status', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||||
|
const payload = await jsonOrNull<GitHubAuthStatus & { error?: string }>(response);
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new Error(payload?.error || response.statusText || 'Failed to load GitHub status');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
async authStart(): Promise<GitHubDeviceFlowStart> {
|
||||||
|
const response = await fetch('/api/github/auth/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const payload = await jsonOrNull<GitHubDeviceFlowStart & { error?: string }>(response);
|
||||||
|
if (!response.ok || !payload || !('deviceCode' in payload)) {
|
||||||
|
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to start GitHub auth');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
async authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete> {
|
||||||
|
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 jsonOrNull<GitHubDeviceFlowComplete & { error?: string }>(response);
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to complete GitHub auth');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
async authDisconnect(): Promise<{ removed: boolean }> {
|
||||||
|
const response = await fetch('/api/github/auth', { method: 'DELETE', headers: { Accept: 'application/json' } });
|
||||||
|
const payload = await jsonOrNull<{ removed?: boolean; error?: string }>(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.error || response.statusText || 'Failed to disconnect GitHub');
|
||||||
|
}
|
||||||
|
return { removed: Boolean(payload?.removed) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async me(): Promise<GitHubUserSummary> {
|
||||||
|
const response = await fetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||||
|
const payload = await jsonOrNull<GitHubUserSummary & { error?: string }>(response);
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new Error(payload?.error || response.statusText || 'Failed to fetch GitHub user');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
async prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/github/pr/status?directory=${encodeURIComponent(directory)}&branch=${encodeURIComponent(branch)}`,
|
||||||
|
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||||
|
);
|
||||||
|
const payload = await jsonOrNull<GitHubPullRequestStatus & { error?: string }>(response);
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new Error(payload?.error || response.statusText || 'Failed to load PR status');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
async prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest> {
|
||||||
|
const response = await fetch('/api/github/pr/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
|
||||||
|
if (!response.ok || !body) {
|
||||||
|
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to create PR');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
|
||||||
|
const response = await fetch('/api/github/pr/merge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const body = await jsonOrNull<GitHubPullRequestMergeResult & { error?: string }>(response);
|
||||||
|
if (!response.ok || !body) {
|
||||||
|
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to merge PR');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
|
async prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult> {
|
||||||
|
const response = await fetch('/api/github/pr/ready', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const body = await jsonOrNull<GitHubPullRequestReadyResult & { error?: string }>(response);
|
||||||
|
if (!response.ok || !body) {
|
||||||
|
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to mark PR ready');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { createWebPermissionsAPI } from './permissions';
|
|||||||
import { createWebNotificationsAPI } from './notifications';
|
import { createWebNotificationsAPI } from './notifications';
|
||||||
import { createWebToolsAPI } from './tools';
|
import { createWebToolsAPI } from './tools';
|
||||||
import { createWebPushAPI } from './push';
|
import { createWebPushAPI } from './push';
|
||||||
|
import { createWebGitHubAPI } from './github';
|
||||||
|
|
||||||
export const createWebAPIs = (): RuntimeAPIs => ({
|
export const createWebAPIs = (): RuntimeAPIs => ({
|
||||||
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
|
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
|
||||||
@@ -16,6 +17,7 @@ export const createWebAPIs = (): RuntimeAPIs => ({
|
|||||||
settings: createWebSettingsAPI(),
|
settings: createWebSettingsAPI(),
|
||||||
permissions: createWebPermissionsAPI(),
|
permissions: createWebPermissionsAPI(),
|
||||||
notifications: createWebNotificationsAPI(),
|
notifications: createWebNotificationsAPI(),
|
||||||
|
github: createWebGitHubAPI(),
|
||||||
push: createWebPushAPI(),
|
push: createWebPushAPI(),
|
||||||
tools: createWebToolsAPI(),
|
tools: createWebToolsAPI(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user