diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2e812f1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + # Tests run on every OS the release workflows build on — otherwise a tagged + # release is the FIRST time the suite ever executes on windows/mac, and a + # failure there yields a half-published release instead of a red PR. + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + - name: Type-check + run: npm run check-types + + - name: Test + run: npm test + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + - name: Engine purity guard + run: npm run check-purity + + - name: Package extension (smoke-test the build) + run: | + npm run package + npx @vscode/vsce package -o gitstudio.vsix --no-dependencies \ + --baseImagesUrl https://github.com/GitStudioHQ/gitstudio/raw/HEAD/apps/extension \ + --baseContentUrl https://github.com/GitStudioHQ/gitstudio/blob/HEAD/apps/extension + working-directory: apps/extension + + - uses: actions/upload-artifact@v4 + with: + name: gitstudio-vsix + path: apps/extension/gitstudio.vsix + + - name: Build desktop bundles (smoke-test) + run: npm run build --workspace apps/desktop diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 0000000..acd4b85 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,132 @@ +name: Release Desktop + +# Builds the GitStudio desktop (Electron) app for macOS (Apple Silicon + Intel), +# Windows, and Linux and uploads the per-OS installers (.dmg / .exe / .AppImage / +# .deb) to the GitHub Release when an `app-v*` tag is pushed. +# +# Cut a release: +# 1. bump "version" in apps/desktop/package.json +# 2. git tag app-v1.0.0 && git push origin app-v1.0.0 +# +# Structure: ONE create-release job makes the Release (so four matrix jobs never +# race `gh release create` against each other), then the per-OS build jobs only +# upload assets. Desktop releases stay the repo's "latest" release — the in-app +# auto-updater resolves /releases/latest, and extension (`ext-v*`) releases are +# created with --latest=false so they can never hijack that pointer. +# +# Code-signing/notarization are OPTIONAL: electron-builder builds UNSIGNED when +# the CSC_*/APPLE_* secrets are absent. With no secrets you still get working +# (unsigned) installers attached to the Release. + +on: + push: + tags: + - 'app-v*.*.*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: "Guard: tag matches package.json version" + if: startsWith(github.ref, 'refs/tags/') + working-directory: apps/desktop + run: | + node -e ' + const v = require("./package.json").version; + const tag = process.env.GITHUB_REF_NAME.replace(/^app-v/, ""); + if (v !== tag) { + console.error(`Tag app-v${tag} != package.json version ${v}`); + process.exit(1); + }' + + - name: Create the GitHub Release (once, before the matrix) + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + echo "Release ${GITHUB_REF_NAME} already exists." + exit 0 + fi + NOTES="docs/releases/${GITHUB_REF_NAME}.md" + if [ -f "$NOTES" ]; then + gh release create "${GITHUB_REF_NAME}" --latest \ + --title "GitStudio ${GITHUB_REF_NAME#app-v}" --notes-file "$NOTES" + else + gh release create "${GITHUB_REF_NAME}" --latest --generate-notes \ + --title "GitStudio ${GITHUB_REF_NAME#app-v}" + fi + + build: + needs: create-release + strategy: + fail-fast: false + matrix: + include: + # Build each mac arch NATIVELY (macos-14 = Apple Silicon, macos-15-intel + # = x64; macos-13 was retired by GitHub in Dec 2025) so node-pty's + # per-arch prebuild loads without cross-compiling. + # + # NOTE: mac deliberately does NOT upload latest-mac.yml. Both arch runners + # emit an identically-named latest-mac.yml, so a --clobber upload would + # leave the Release with ONE arch's feed and mis-update the other; the + # app skips update checks on macOS instead (see autoUpdate.ts). Windows/ + # Linux are single-arch, so their feeds are safe. + - os: macos-14 + artifact: 'apps/desktop/release/*.dmg apps/desktop/release/*.zip' + - os: macos-15-intel + artifact: 'apps/desktop/release/*.dmg apps/desktop/release/*.zip' + - os: windows-latest + artifact: 'apps/desktop/release/*.exe apps/desktop/release/*.yml apps/desktop/release/*.blockmap' + # ubuntu-22.04 (not -latest): the AppImage links the build host's glibc, + # so building on 24.04 (glibc 2.39) breaks Ubuntu 22.04/Debian 12 users. + # No *.blockmap here — AppImage/deb don't emit one, and a non-matching + # glob would fail the upload. + - os: ubuntu-22.04 + artifact: 'apps/desktop/release/*.AppImage apps/desktop/release/*.deb apps/desktop/release/*.yml' + runs-on: ${{ matrix.os }} + env: + # electron-builder reads these; when absent it builds UNSIGNED (it does not + # fail). Provide them as repo secrets to enable signing/notarization. + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} + WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install (workspace root) + run: npm ci + + - name: Type-check + run: npm run check-types --workspaces --if-present + + - name: Test + run: npm test + + - name: Build bundles (main / preload / renderer) + run: npm run build --workspace apps/desktop + + - name: Build installer (electron-builder) + working-directory: apps/desktop + run: npx electron-builder --publish never + + - name: Upload installers to the GitHub Release + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: gh release upload "${GITHUB_REF_NAME}" ${{ matrix.artifact }} --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..782699b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +name: Release Extension + +# Publishes the GitStudio extension to the VS Code Marketplace and Open VSX when +# an `ext-vX.Y.Z` tag is pushed, then attaches the .vsix to the GitHub Release. +# (The desktop app has its own channel: `app-v*` tags -> .github/workflows/release-desktop.yml.) +# +# One-time setup — add two repo secrets: +# VSCE_PAT Azure DevOps PAT, scope "Marketplace > Manage", for publisher `gitstudio` +# OVSX_PAT Open VSX access token (namespace `gitstudio` must exist) +# +# Cut a release: +# 1. bump "version" in apps/extension/package.json (+ a CHANGELOG entry) +# 2. git tag ext-v1.0.0 && git push origin ext-v1.0.0 +# +# The .vsix is attached to the Release BEFORE publishing, so even a failed +# publish leaves a downloadable artifact; the publish steps FAIL LOUDLY when a +# secret is missing (a silent green run that shipped nothing cost us a launch). +# After adding the missing secret, re-run via workflow_dispatch — it rebuilds +# and publishes the current package.json version without needing a new tag. + +on: + push: + tags: + - 'ext-v*.*.*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/extension + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + # The tag names the release, but vsce/electron-builder ship whatever is in + # package.json — a mismatch publishes the WRONG version under this tag. + - name: "Guard: tag matches package.json version" + if: startsWith(github.ref, 'refs/tags/') + run: | + node -e ' + const v = require("./package.json").version; + const tag = process.env.GITHUB_REF_NAME.replace(/^ext-v/, ""); + if (v !== tag) { + console.error(`Tag ext-v${tag} != package.json version ${v}`); + process.exit(1); + }' + + - name: Install (workspace root) + run: npm ci + working-directory: . + + - name: Type-check + run: npm run check-types --workspaces --if-present + working-directory: . + + - name: Test + run: npm test + working-directory: . + + # --baseImagesUrl: vsce's default rewrite of relative README images uses + # the repo ROOT (https://github.com/…/raw/HEAD/media/…), but this is a + # monorepo — the images live under apps/extension. Without this flag every + # store screenshot 404s. + - name: Package + run: | + npm run package + npx @vscode/vsce package -o gitstudio.vsix --no-dependencies \ + --baseImagesUrl https://github.com/GitStudioHQ/gitstudio/raw/HEAD/apps/extension \ + --baseContentUrl https://github.com/GitStudioHQ/gitstudio/blob/HEAD/apps/extension + + # `--latest=false` is load-bearing: the desktop app's auto-updater reads + # /releases/latest, and an extension release marked "latest" would point + # it at a tag with no latest.yml feed (update checks 404 forever). + - name: Attach .vsix to the GitHub Release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + NOTES="../../docs/releases/${GITHUB_REF_NAME}.md" + if [ -f "$NOTES" ]; then + gh release create "${GITHUB_REF_NAME}" gitstudio.vsix --latest=false \ + --title "GitStudio extension ${GITHUB_REF_NAME#ext-v}" --notes-file "$NOTES" \ + || gh release upload "${GITHUB_REF_NAME}" gitstudio.vsix --clobber + else + gh release create "${GITHUB_REF_NAME}" gitstudio.vsix --latest=false --generate-notes \ + || gh release upload "${GITHUB_REF_NAME}" gitstudio.vsix --clobber + fi + + - name: Publish to VS Code Marketplace + run: | + if [ -z "$VSCE_PAT" ]; then + echo "::error::VSCE_PAT secret is missing — Marketplace publish did NOT run. Add the secret, then re-run this workflow (workflow_dispatch)." + exit 1 + fi + npx @vscode/vsce publish --packagePath gitstudio.vsix + + - name: Publish to Open VSX + run: | + if [ -z "$OVSX_PAT" ]; then + echo "::error::OVSX_PAT secret is missing — Open VSX publish did NOT run. Add the secret, then re-run this workflow (workflow_dispatch)." + exit 1 + fi + npx ovsx publish gitstudio.vsix diff --git a/.gitignore b/.gitignore index 9a7d130..9f4b149 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ node_modules/ dist/ out/ +release/ *.vsix .DS_Store .vscode-test/ + +# IDE dependency-graph plugin artifacts +.dependencygraph/ diff --git a/HANDOFF-AI-MCP.md b/HANDOFF-AI-MCP.md new file mode 100644 index 0000000..f6a89f8 --- /dev/null +++ b/HANDOFF-AI-MCP.md @@ -0,0 +1,71 @@ +# Handoff — AI + Agents + MCP (overnight build) + +## What shipped + +Two features the user asked for, built and verified overnight: + +1. **Connect any AI model in the app** + AI/agent capabilities that leverage + your own subscriptions/keys (or a local model). +2. **A first-class MCP server** exposing this repo's Git capabilities to any + outside agent, used wisely (least-privilege). + +Both reuse **one shared tool catalog** (`@gitstudio/ai/gitTools`), so the in-app +agent and the MCP server stay in lockstep. + +See [`docs/ai-and-agents.md`](docs/ai-and-agents.md) and +[`apps/mcp/README.md`](apps/mcp/README.md). + +## ⚠️ Two branches — why, and how to merge + +While I was working, **a second Claude session was concurrently editing the +desktop app** (a security/theme hardening pass: `cloneUrl.ts`, `security.test.ts`, +`theme-boot.js`, and edits to `renderer.ts` / `main.ts` / `gitBridge.ts` / +`app.css`). To avoid silently clobbering its work, I split the build: + +- **`claude/redesign-masterpiece`** (the shared branch) got the **collision-free + foundation** — commit `7e31e36`: + - `packages/ai` (`@gitstudio/ai`) — providers, catalog, connections, tasks, + agent loop, shared git-tool catalog. + - `packages/git-service/GitToolHost.ts` — the tool host over a real repo. + - `apps/mcp` (`gitstudio-mcp`) — the standalone MCP server. +- **`claude/ai-mcp-desktop`** (this branch, from `7e31e36`) got the **desktop + integration** — commit `0fd04c5` — built in an isolated git worktree so it + never raced the other session: `main/aiBridge.ts`, `main/mcpConfig.ts`, + `renderer/{assistant,aiSettings}.ts`, plus minimal edits to `renderer.ts`, + `main.ts`, `shared/ipc.ts`, `styles/app.css`, `package.json`. + +### To land everything + +```bash +# 1. Let the other session commit its desktop changes on claude/redesign-masterpiece. +# 2. Merge this branch in: +git checkout claude/redesign-masterpiece +git merge claude/ai-mcp-desktop +# 3. Expect conflicts in the files BOTH sessions touched: +# renderer.ts (nav tab + settings cards + route — small, localized), +# main.ts (AiBridge construct + ai:* handlers — one contiguous block), +# app.css (AI styles appended at the very end), +# shared/ipc.ts (AI channels/events/types — contiguous blocks). +# My additions are deliberately localized + contiguous to keep these easy. +# 4. npm install (reconciles the lockfile: new @gitstudio/ai dep edges) +# 5. npm run check-types && npm test && (cd apps/mcp && npm run build) +``` + +The worktree lives at `/tmp/gitstudio-ai-wt` (throwaway; remove with +`git worktree remove /tmp/gitstudio-ai-wt` after merging). + +## Status + +- **Tests:** 232 pass in the main repo (16 ai + 73 git-service incl. 5 new + tool-host + 13 mcp + the rest). Desktop typechecks (main + renderer) and + bundles cleanly. (Running git-service tests *inside the /tmp worktree* falsely + fails on a `tsx`+symlink path quirk — they pass in the real checkout.) +- **Verified end-to-end:** the MCP server over real stdio (initialize → + tools/list → tools/call → resources/read), read-only vs `--write` gating. +- **Not done (deliberate, to limit merge pain):** deep inline ✨ buttons in the + commit composer / diff views — the Assistant already covers those flows + ("draft a commit", "summarize my changes") in one surface. The task functions + exist in `@gitstudio/ai/tasks` and `ai:task` IPC is wired, so adding inline + buttons later is small. +- Nothing here gates Git: with no model connected, the AI affordances stay + hidden and the app behaves exactly as before. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a620b68 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 GitStudio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..21d77cd --- /dev/null +++ b/NOTICE @@ -0,0 +1,14 @@ +GitStudio +Copyright 2026 GitStudio + +This product includes software developed for the GitStudio project +(https://gitstudio.dev). + +Portions of the shared engine and webview UI originate from Merge Studio +(https://github.com/GitStudioHQ/merge-studio), licensed under the MIT License, +and are redistributed here under the Apache License, Version 2.0. + +This product depends on third-party software, including: +- Monaco Editor (MIT), Microsoft Corporation +- vscode-diff (MIT) +- DOMPurify (Apache-2.0 / MPL-2.0) diff --git a/README.md b/README.md index 87a5582..15d664e 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,156 @@ +
+
+
- A JetBrains-grade Git experience for VS Code & Cursor — the whole workflow, not just one piece. + A free, open-source, JetBrains-grade Git suite for VS Code, Cursor, and desktop.
+ + +Logo & brand assets live in brand/.
+ --- +## Get GitStudio + +### VS Code / Cursor extension + +Install **GitStudio** from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=gitstudio.gitstudio) or [Open VSX](https://open-vsx.org/extension/gitstudio/gitstudio) (VSCodium, Gitpod, …), or from the command line: + +```bash +code --install-extension gitstudio.gitstudio # VS Code +cursor --install-extension gitstudio.gitstudio # Cursor +``` + +### Desktop app + +Download the installer for your platform from the [latest GitHub Release](https://github.com/GitStudioHQ/gitstudio/releases/latest) or from [gitstudio.dev](https://gitstudio.dev): + +| Platform | Installer | +|---|---| +| **macOS — Apple Silicon** | `GitStudio-` viewer (no more github.com).
+ */
+export async function jobLog(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { jobId: number },
+): Promise {
+ const res = await fetchSignedRedirect(
+ bearer(client),
+ `/repos/${enc(owner)}/${enc(repo)}/actions/jobs/${req.jobId}/logs`,
+ );
+ return res.text();
+}
+
+/**
+ * Plain-text logs for a WHOLE run, assembled from its jobs. The native
+ * `…/runs/{id}/logs` endpoint returns a ZIP (heavy + needs unzip in-process);
+ * instead we fetch the run's jobs and concatenate each job's `jobLog` under a
+ * `=== job name ===` banner — the same text, streamable straight into the viewer.
+ * One job's failure is annotated inline rather than failing the whole aggregate.
+ */
+export async function runLog(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { runId: number },
+): Promise {
+ const jobsRaw = await client.request<{ jobs?: RawJob[] }>(
+ "GET",
+ `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${req.runId}/jobs?per_page=100`,
+ );
+ const jobs = jobsRaw.jobs ?? [];
+ if (jobs.length === 0) return "This run reported no jobs.";
+ const parts: string[] = [];
+ for (const j of jobs) {
+ const name = j.name ?? `job ${j.id}`;
+ parts.push(`=== ${name} ===`);
+ try {
+ parts.push((await jobLog(client, owner, repo, { jobId: j.id })).trimEnd());
+ } catch (err) {
+ parts.push(`[logs unavailable: ${err instanceof Error ? err.message : String(err)}]`);
+ }
+ parts.push(""); // blank line between jobs
+ }
+ return parts.join("\n");
+}
+
+/** Artifacts produced by a run (GET /actions/runs/{id}/artifacts). */
+export async function artifacts(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ runId: number,
+): Promise {
+ const raw = await client.request<{ artifacts?: RawArtifact[] }>(
+ "GET",
+ `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${runId}/artifacts?per_page=100`,
+ );
+ return (raw.artifacts ?? []).map(mapArtifact);
+}
+
+/** All repo Actions secrets — names + updatedAt only (values are write-only). */
+export async function secrets(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+): Promise {
+ const raw = await client.request<{ secrets?: RawSecret[] }>(
+ "GET",
+ `/repos/${enc(owner)}/${enc(repo)}/actions/secrets?per_page=100`,
+ );
+ return (raw.secrets ?? []).map(mapSecret);
+}
+
+/** All repo Actions variables — name, value, updatedAt (values ARE readable). */
+export async function variables(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+): Promise {
+ const raw = await client.request<{ variables?: RawVariable[] }>(
+ "GET",
+ `/repos/${enc(owner)}/${enc(repo)}/actions/variables?per_page=100`,
+ );
+ return (raw.variables ?? []).map(mapVariable);
+}
+
+// ── Mutations (never throw — return CommitActionResult) ──────────────────────
+
+const ok = (msg?: string): CommitActionResult => ({ ok: true, changed: false, message: msg });
+const fail = (err: unknown): CommitActionResult => ({
+ ok: false,
+ changed: false,
+ message: err instanceof Error ? err.message : String(err),
+});
+
+/** Re-run every job in a run (POST /actions/runs/{id}/rerun). */
+export async function rerunRun(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ id: number,
+): Promise {
+ try {
+ await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}/rerun`, {});
+ return ok();
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+/** Re-run only the failed jobs (POST /actions/runs/{id}/rerun-failed-jobs). */
+export async function rerunFailedJobs(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ id: number,
+): Promise {
+ try {
+ await client.requestBody(
+ "POST",
+ `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}/rerun-failed-jobs`,
+ {},
+ );
+ return ok();
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+/** Cancel an in-progress run (POST /actions/runs/{id}/cancel). */
+export async function cancelRun(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ id: number,
+): Promise {
+ try {
+ await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}/cancel`, {});
+ return ok();
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+/**
+ * Manually trigger a `workflow_dispatch` (POST /actions/workflows/{id}/dispatches).
+ * GitHub requires every input value to be a STRING in the payload (even
+ * booleans/numbers); the renderer already collects strings and omits empties so
+ * the workflow's declared defaults apply server-side. Returns 204 No Content on
+ * success — `requestBody` treats any 2xx as success.
+ */
+export async function dispatchWorkflow(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { workflowId: number; ref: string; inputs: Record },
+): Promise {
+ try {
+ const body: { ref: string; inputs?: Record } = { ref: req.ref };
+ if (req.inputs && Object.keys(req.inputs).length) body.inputs = req.inputs;
+ await client.requestBody(
+ "POST",
+ `/repos/${enc(owner)}/${enc(repo)}/actions/workflows/${req.workflowId}/dispatches`,
+ body,
+ );
+ return ok();
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+/**
+ * Download a run artifact's ZIP to the user's Downloads folder
+ * (GET /actions/artifacts/{id}/zip → signed redirect → bytes → ~/Downloads).
+ * Returns ok with the saved absolute path so the renderer can toast it. The
+ * filename is sanitized and `.zip`-suffixed; we never overwrite blindly — a
+ * collision gets a `(1)`, `(2)`… suffix. Expired artifacts 410 → clean message.
+ */
+export async function downloadArtifact(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { id: number; name: string },
+): Promise {
+ try {
+ const res = await fetchSignedRedirect(
+ bearer(client),
+ `/repos/${enc(owner)}/${enc(repo)}/actions/artifacts/${req.id}/zip`,
+ );
+ const bytes = Buffer.from(await res.arrayBuffer());
+ const dest = await uniqueDownloadPath(safeFileName(req.name) + ".zip");
+ await writeFile(dest, bytes);
+ return ok(`Saved to ${dest}`);
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+/**
+ * Create or update a repo Actions secret. Encrypting the value needs a libsodium
+ * sealed box against the repo's public key — and this app bundles NO crypto
+ * dependency (no libsodium-wrappers / tweetnacl), so we cannot encrypt safely.
+ * Rather than ship a broken write, we return a clear, actionable message. Listing
+ * + delete (and ALL of variables) work without crypto and are fully implemented.
+ */
+export async function setSecret(
+ _client: GitHubClient,
+ _owner: string,
+ _repo: string,
+ _req: { name: string; value: string },
+): Promise {
+ return {
+ ok: false,
+ changed: false,
+ message:
+ "Creating or updating secrets needs the libsodium encryption library, which isn't bundled in this build. You can still delete secrets here; to add one, use github.com for now.",
+ };
+}
+
+/** Delete a repo Actions secret (DELETE /actions/secrets/{name}). */
+export async function deleteSecret(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ name: string,
+): Promise {
+ try {
+ await client.requestBody(
+ "DELETE",
+ `/repos/${enc(owner)}/${enc(repo)}/actions/secrets/${enc(name)}`,
+ {},
+ );
+ return ok();
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+/**
+ * Create or update a repo Actions variable. Variables are plaintext (no crypto),
+ * so this works fully. GitHub has no upsert: PATCH the existing variable, and on
+ * a 404 (it doesn't exist yet) fall back to POST to create it.
+ */
+export async function setVariable(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { name: string; value: string },
+): Promise {
+ const base = `/repos/${enc(owner)}/${enc(repo)}/actions/variables`;
+ try {
+ await client.requestBody("PATCH", `${base}/${enc(req.name)}`, {
+ name: req.name,
+ value: req.value,
+ });
+ return ok();
+ } catch (err) {
+ if (!isNotFound(err)) return fail(err);
+ // Doesn't exist yet → create it.
+ try {
+ await client.requestBody("POST", base, { name: req.name, value: req.value });
+ return ok();
+ } catch (createErr) {
+ return fail(createErr);
+ }
+ }
+}
+
+/** Delete a repo Actions variable (DELETE /actions/variables/{name}). */
+export async function deleteVariable(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ name: string,
+): Promise {
+ try {
+ await client.requestBody(
+ "DELETE",
+ `/repos/${enc(owner)}/${enc(repo)}/actions/variables/${enc(name)}`,
+ {},
+ );
+ return ok();
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ── Download helpers ─────────────────────────────────────────────────────────
+
+/** The client's 404 maps to "Not found on GitHub." — detect it to drive the
+ * variable PATCH→POST upsert without a second HEAD round-trip. */
+function isNotFound(err: unknown): boolean {
+ return err instanceof Error && /not found on github/i.test(err.message);
+}
+
+/** Sanitize an artifact name into a single safe path segment (no separators,
+ * control chars, or leading dots), so the save path can't escape Downloads. */
+function safeFileName(name: string): string {
+ const cleaned = (name || "artifact")
+ .replace(/[/\\:*?"<>| -]+/g, "_")
+ .replace(/^\.+/, "")
+ .replace(/[. ]+$/, "")
+ .slice(0, 200)
+ .trim();
+ return cleaned || "artifact";
+}
+
+/** A non-clobbering path in ~/Downloads: "name.zip", then "name (1).zip", … */
+async function uniqueDownloadPath(fileName: string): Promise {
+ const dir = join(homedir(), "Downloads");
+ const dot = fileName.lastIndexOf(".");
+ const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
+ const ext = dot > 0 ? fileName.slice(dot) : "";
+ for (let i = 0; i < 1000; i++) {
+ const candidate = join(dir, i === 0 ? fileName : `${stem} (${i})${ext}`);
+ if (!(await pathExists(candidate))) return candidate;
+ }
+ // Astronomically unlikely; fall back to a timestamped name.
+ return join(dir, `${stem}-${Date.now()}${ext}`);
+}
+
+/** True when a file/dir exists at `p` (no throw). */
+async function pathExists(p: string): Promise {
+ try {
+ await access(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// ── Minimal YAML reader for `on.workflow_dispatch.inputs:` ────────────────────
+
+/**
+ * A deliberately small indent-walking parser — enough to drive the dispatch
+ * form, no external YAML dependency. Handles scalar inputs plus `required`,
+ * `default`, `description`, `type`, and `type: choice` with an `options:` list.
+ * Returns [] for the list form `on: [workflow_dispatch]` and when there are no
+ * inputs. Best-effort: if it yields nothing the form still shows the ref field,
+ * which is a valid dispatch (POST accepts `{ ref }` with no inputs).
+ */
+function parseDispatchInputs(yaml: string): WorkflowDispatchInput[] {
+ const lines = yaml.split(/\r?\n/);
+ const indentOf = (s: string): number => s.length - s.replace(/^\s+/, "").length;
+
+ // Find `workflow_dispatch:` then its `inputs:` child.
+ let i = lines.findIndex((l) => /^\s*workflow_dispatch\s*:/.test(l));
+ if (i < 0) return [];
+ const wdIndent = indentOf(lines[i]);
+ let inputsIndent = -1;
+ i += 1;
+ for (; i < lines.length; i++) {
+ const l = lines[i];
+ if (l.trim() === "" || l.trim().startsWith("#")) continue;
+ const ind = indentOf(l);
+ if (ind <= wdIndent) return []; // left the workflow_dispatch block — no inputs
+ if (/^\s*inputs\s*:/.test(l)) {
+ inputsIndent = ind;
+ i += 1;
+ break;
+ }
+ }
+ if (inputsIndent < 0) return [];
+
+ const out: WorkflowDispatchInput[] = [];
+ let cur: WorkflowDispatchInput | undefined;
+ let inOptions = false;
+ // The column of the first real input key; once locked, only keys at exactly
+ // this indent start a new input — so child props (options:, description:, …)
+ // at a deeper indent are never misread as inputs.
+ let keyIndent = -1;
+ for (; i < lines.length; i++) {
+ const rawLine = lines[i];
+ if (rawLine.trim() === "" || rawLine.trim().startsWith("#")) continue;
+ const ind = indentOf(rawLine);
+ if (ind <= inputsIndent) break; // dedented out of `inputs:`
+ const line = rawLine.trim();
+
+ // A new input key sits one level under `inputs:`.
+ const keyM = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*$/);
+ const isNewKey =
+ !!keyM &&
+ !line.startsWith("-") &&
+ (keyIndent < 0 ? ind <= inputsIndent + 4 : ind === keyIndent);
+ if (isNewKey && keyM) {
+ if (keyIndent < 0) keyIndent = ind;
+ cur = { name: keyM[1], description: "", required: false, default: "", type: "string" };
+ out.push(cur);
+ inOptions = false;
+ continue;
+ }
+ if (!cur) continue;
+
+ if (inOptions) {
+ const optM = line.match(/^-\s*(.+)$/);
+ if (optM) {
+ (cur.options ??= []).push(stripYamlScalar(optM[1]));
+ continue;
+ }
+ inOptions = false;
+ }
+
+ const kv = line.match(/^(description|required|default|type|options)\s*:\s*(.*)$/);
+ if (!kv) continue;
+ const key = kv[1];
+ const value = kv[2].trim();
+ if (key === "options") {
+ inOptions = true;
+ cur.options = [];
+ } else if (key === "required") {
+ cur.required = /^true$/i.test(stripYamlScalar(value));
+ } else if (key === "type") {
+ cur.type = stripYamlScalar(value) || "string";
+ } else if (key === "default") {
+ cur.default = stripYamlScalar(value);
+ } else if (key === "description") {
+ cur.description = stripYamlScalar(value);
+ }
+ }
+ return out;
+}
+
+/** Strip surrounding single/double quotes from a YAML scalar. */
+function stripYamlScalar(s: string): string {
+ const t = s.trim();
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
+ return t.slice(1, -1);
+ }
+ return t;
+}
diff --git a/apps/desktop/src/main/github/gists.ts b/apps/desktop/src/main/github/gists.ts
new file mode 100644
index 0000000..aff2202
--- /dev/null
+++ b/apps/desktop/src/main/github/gists.ts
@@ -0,0 +1,150 @@
+// Gists — the user-scoped GitHub section (NOT repo-scoped). These standalone
+// functions are invoked from main.ts via `github.withClient((c) => gistX(c, …))`,
+// so they take only the client (no owner/repo). They hit `/gists` and
+// `/gists/{id}` directly, authed as the current user via the Bearer token.
+//
+// Convention (post-2026-06-27): read functions THROW on API error so the
+// renderer can show a real errorState + Retry; mutation functions never throw —
+// they return a CommitActionResult ({ ok, changed, message? }) so the view can
+// toast the message. `changed` is always false for gist ops (gists touch no
+// local git state); on create, `message` carries the new gist id so the caller
+// can select it after a refresh.
+
+import { GitHubClient, enc, mapUser, RawUser } from "../githubClient";
+import type {
+ CommitActionResult,
+ GistCreate,
+ GistFile,
+ GistInfo,
+ GistUpdate,
+} from "../../shared/ipc";
+
+// ── Raw API shapes (the JSON GitHub returns) ─────────────────────────────────
+
+interface RawGistFile {
+ filename?: string;
+ language?: string | null;
+ type?: string;
+ size?: number;
+ raw_url?: string;
+ content?: string;
+ truncated?: boolean;
+}
+
+interface RawGist {
+ id: string;
+ description: string | null;
+ public: boolean;
+ html_url: string;
+ owner?: RawUser | null;
+ created_at: string;
+ updated_at: string;
+ comments?: number;
+ files: Record;
+}
+
+// ── Mappers (raw → the typed ipc shapes the renderer consumes) ───────────────
+
+function mapGistFile(key: string, f: RawGistFile): GistFile {
+ return {
+ filename: f.filename ?? key,
+ language: f.language ?? "",
+ type: f.type ?? "",
+ size: f.size ?? 0,
+ rawUrl: f.raw_url ?? "",
+ content: f.content ?? "",
+ truncated: f.truncated ?? false,
+ };
+}
+
+function mapGist(g: RawGist): GistInfo {
+ const files: GistFile[] = Object.entries(g.files ?? {})
+ .filter((entry): entry is [string, RawGistFile] => entry[1] != null)
+ .map(([key, f]) => mapGistFile(key, f));
+ return {
+ id: g.id,
+ description: g.description ?? "",
+ public: g.public,
+ htmlUrl: g.html_url,
+ owner: mapUser(g.owner ?? null),
+ createdAt: g.created_at,
+ updatedAt: g.updated_at,
+ fileCount: files.length,
+ files,
+ comments: g.comments ?? 0,
+ };
+}
+
+function errMessage(err: unknown): string {
+ return err instanceof Error ? err.message : String(err);
+}
+
+// ── Reads (throw on error) ───────────────────────────────────────────────────
+
+/** The authenticated user's own gists, newest first (GitHub default: updated desc).
+ * The list payload carries file METADATA only — file `content` is null here, so
+ * the detail view re-fetches the full gist via `getGist`. */
+export async function listGists(client: GitHubClient): Promise {
+ const raw = await client.request("GET", "/gists?per_page=100");
+ return raw.map(mapGist);
+}
+
+/** A single gist with full file `content` (and `truncated` flags for big files). */
+export async function getGist(client: GitHubClient, id: string): Promise {
+ const raw = await client.request("GET", `/gists/${enc(id)}`);
+ return mapGist(raw);
+}
+
+// ── Mutations (return CommitActionResult; never throw) ───────────────────────
+
+/** Create a single-file gist. On success, `message` carries the new gist id. */
+export async function createGist(
+ client: GitHubClient,
+ input: GistCreate,
+): Promise {
+ try {
+ const created = await client.request("POST", "/gists", {
+ description: input.description,
+ public: input.public,
+ files: { [input.filename]: { content: input.content } },
+ });
+ return { ok: true, changed: false, message: created.id };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+/** Edit a gist's description and its (single) file. GitHub keys files by their
+ * CURRENT name; to rename, set `filename` on the value to the new name; to
+ * change content, set `content`. Keep the key = the existing filename. */
+export async function updateGist(
+ client: GitHubClient,
+ input: GistUpdate,
+): Promise {
+ try {
+ const file: { content: string; filename?: string } = { content: input.content };
+ if (input.newFilename && input.newFilename !== input.filename) {
+ file.filename = input.newFilename;
+ }
+ await client.request("PATCH", `/gists/${enc(input.id)}`, {
+ description: input.description,
+ files: { [input.filename]: file },
+ });
+ return { ok: true, changed: false };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+/** Permanently delete a gist (204 on success). */
+export async function deleteGist(
+ client: GitHubClient,
+ id: string,
+): Promise {
+ try {
+ await client.request("DELETE", `/gists/${enc(id)}`);
+ return { ok: true, changed: false };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
diff --git a/apps/desktop/src/main/github/issues.ts b/apps/desktop/src/main/github/issues.ts
new file mode 100644
index 0000000..e71b231
--- /dev/null
+++ b/apps/desktop/src/main/github/issues.ts
@@ -0,0 +1,406 @@
+// Issues — the repo-scoped GitHub Issues surface for the desktop app.
+//
+// Standalone, self-contained functions over the shared `GitHubClient`
+// primitives. main.ts wires each channel through `github.withRepo((c, o, r) =>
+// …)`, so every function here takes `(client, owner, repo, …args)`.
+//
+// Convention (post-2026-06-27):
+// • READ functions THROW on error (the client primitives already throw via
+// `toError`) so the renderer can show a real error state.
+// • MUTATION functions never throw — they return a CommitActionResult-shaped
+// `{ ok, changed, message? }` so the renderer can toast cleanly.
+//
+// Everything here is REST (Issues live under the OAuth `repo` scope, same as
+// PRs); GraphQL is only needed for Projects v2, which lives elsewhere.
+
+import { GitHubClient, enc, mapUser, type RawUser } from "../githubClient";
+import type {
+ CommitActionResult,
+ GitHubUser,
+ IssueComment,
+ IssueDetail,
+ IssueInfo,
+ MilestoneInfo,
+ RepoLabel,
+} from "../../shared/ipc";
+
+// ── Raw GitHub payloads (only what we read) ──────────────────────────────────
+
+interface RawLabelRef {
+ name: string;
+ color: string;
+}
+interface RawIssue {
+ number: number;
+ title: string;
+ body: string | null;
+ state: string;
+ html_url: string;
+ user: RawUser | null;
+ created_at: string;
+ updated_at: string;
+ comments: number;
+ labels?: (RawLabelRef | string)[];
+ assignees?: RawUser[];
+ pull_request?: unknown;
+}
+interface RawIssueComment {
+ id: number;
+ user?: RawUser | null;
+ body?: string | null;
+ created_at: string;
+}
+interface RawRepoLabel {
+ name: string;
+ color: string;
+ description?: string | null;
+}
+interface RawMilestone {
+ number: number;
+ title: string;
+ state: string;
+ due_on?: string | null;
+ open_issues: number;
+ closed_issues: number;
+}
+
+// ── Mappers ──────────────────────────────────────────────────────────────────
+
+function mapIssue(i: RawIssue): IssueInfo {
+ return {
+ number: i.number,
+ title: i.title,
+ body: i.body,
+ state: i.state,
+ htmlUrl: i.html_url,
+ user: mapUser(i.user),
+ createdAt: i.created_at,
+ updatedAt: i.updated_at,
+ comments: i.comments,
+ labels: (i.labels ?? []).map((l) =>
+ typeof l === "string" ? { name: l, color: "888888" } : { name: l.name, color: l.color },
+ ),
+ assignees: (i.assignees ?? [])
+ .map(mapUser)
+ .filter((u): u is GitHubUser => u !== null),
+ };
+}
+
+function mapComment(c: RawIssueComment): IssueComment {
+ return {
+ id: c.id,
+ author: mapUser(c.user ?? null),
+ body: c.body ?? "",
+ createdAt: c.created_at,
+ };
+}
+
+function mapLabel(l: RawRepoLabel): RepoLabel {
+ return { name: l.name, color: l.color, description: l.description ?? null };
+}
+
+function mapMilestone(m: RawMilestone): MilestoneInfo {
+ return {
+ number: m.number,
+ title: m.title,
+ state: m.state === "closed" ? "closed" : "open",
+ dueOn: m.due_on ?? null,
+ openIssues: m.open_issues,
+ closedIssues: m.closed_issues,
+ };
+}
+
+/** Coerce any thrown value into a clean, user-facing message. */
+function errMessage(err: unknown): string {
+ return err instanceof Error ? err.message : String(err);
+}
+
+// ── Reads (THROW on error) ───────────────────────────────────────────────────
+
+/**
+ * Open (or closed/all) issues for the repo, newest-updated first. The `issues`
+ * endpoint also returns PRs, so we drop anything carrying a `pull_request` node.
+ */
+export async function listIssues(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ state: "open" | "closed" | "all" = "open",
+): Promise {
+ const raw = await client.request(
+ "GET",
+ `/repos/${enc(owner)}/${enc(repo)}/issues?state=${state}&sort=updated&direction=desc&per_page=50`,
+ );
+ return raw.filter((i) => !i.pull_request).map(mapIssue);
+}
+
+/**
+ * One issue plus its comment timeline (oldest → newest, GitHub's default order)
+ * and the current assignee logins. The issue read throws on error; the comments
+ * read is best-effort (a comment-fetch hiccup shouldn't blank the whole detail).
+ */
+export async function getIssueDetail(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ n: number,
+): Promise {
+ const issue = mapIssue(
+ await client.request("GET", `/repos/${enc(owner)}/${enc(repo)}/issues/${n}`),
+ );
+ const comments = await client
+ .request(
+ "GET",
+ `/repos/${enc(owner)}/${enc(repo)}/issues/${n}/comments?per_page=100`,
+ )
+ .then((raw) => raw.map(mapComment))
+ .catch(() => [] as IssueComment[]);
+ return { issue, comments, assignees: issue.assignees.map((a) => a.login) };
+}
+
+/** The repo's defined labels, for the label picker (GET …/labels). */
+export async function listLabels(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+): Promise {
+ const raw = await client.request(
+ "GET",
+ `/repos/${enc(owner)}/${enc(repo)}/labels?per_page=100`,
+ );
+ return raw.map(mapLabel);
+}
+
+/** Repo-level label CRUD (the maintainer's label management surface). */
+export async function createLabel(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { name: string; color: string; description?: string },
+): Promise {
+ try {
+ await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/labels`, {
+ name: req.name,
+ color: req.color.replace(/^#/, ""),
+ description: req.description ?? "",
+ });
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+export async function updateLabel(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { name: string; newName?: string; color?: string; description?: string },
+): Promise {
+ try {
+ const body: Record = {};
+ if (req.newName) body.new_name = req.newName;
+ if (req.color) body.color = req.color.replace(/^#/, "");
+ if (req.description !== undefined) body.description = req.description;
+ await client.requestBody(
+ "PATCH",
+ `/repos/${enc(owner)}/${enc(repo)}/labels/${enc(req.name)}`,
+ body,
+ );
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+export async function deleteLabel(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ name: string,
+): Promise {
+ try {
+ await client.request("DELETE", `/repos/${enc(owner)}/${enc(repo)}/labels/${enc(name)}`);
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+/**
+ * Every milestone (open AND closed) for the repo, for the milestone filter and
+ * the per-issue milestone picker. `state=all` so closed milestones still show
+ * (an issue can carry a closed milestone). Newest-due first is GitHub's default.
+ */
+export async function milestones(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+): Promise {
+ const raw = await client.request(
+ "GET",
+ `/repos/${enc(owner)}/${enc(repo)}/milestones?state=all&per_page=100`,
+ );
+ return raw.map(mapMilestone);
+}
+
+// ── Mutations (never throw — return CommitActionResult) ──────────────────────
+
+/**
+ * Open a new issue. Returns the created issue's `number` so the caller can
+ * select it. This is its own result shape (carries `number`) per the channel.
+ */
+export async function createIssue(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { title: string; body?: string },
+): Promise<{ ok: boolean; number?: number; message?: string }> {
+ const title = req.title.trim();
+ if (!title) {
+ return { ok: false, message: "An issue needs a title." };
+ }
+ try {
+ const created = await client.request(
+ "POST",
+ `/repos/${enc(owner)}/${enc(repo)}/issues`,
+ { title, body: req.body ?? "" },
+ );
+ return { ok: true, number: created.number };
+ } catch (err) {
+ return { ok: false, message: errMessage(err) };
+ }
+}
+
+/** Post a comment on an issue (POST …/issues/{n}/comments). */
+export async function commentIssue(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { number: number; body: string },
+): Promise {
+ const body = req.body.trim();
+ if (!body) {
+ return { ok: false, changed: false, message: "Write a comment first." };
+ }
+ try {
+ await client.requestBody(
+ "POST",
+ `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}/comments`,
+ { body },
+ );
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+/** Close or reopen an issue (PATCH state). */
+export async function setIssueState(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { number: number; state: "open" | "closed" },
+): Promise {
+ try {
+ await client.requestBody("PATCH", `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}`, {
+ state: req.state,
+ });
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+/** Edit an issue's title and/or body (PATCH). */
+export async function editIssue(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { number: number; title?: string; body?: string },
+): Promise {
+ const fields: { title?: string; body?: string } = {};
+ if (typeof req.title === "string") fields.title = req.title.trim();
+ if (typeof req.body === "string") fields.body = req.body;
+ if (fields.title !== undefined && fields.title === "") {
+ return { ok: false, changed: false, message: "An issue needs a title." };
+ }
+ if (fields.title === undefined && fields.body === undefined) {
+ return { ok: false, changed: false, message: "Nothing to update." };
+ }
+ try {
+ await client.requestBody(
+ "PATCH",
+ `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}`,
+ fields,
+ );
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+/**
+ * Replace the issue's full label set (PUT …/issues/{n}/labels). PUT replaces
+ * the whole set, which is exactly what the label-toggle picker needs.
+ */
+export async function setIssueLabels(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { number: number; labels: string[] },
+): Promise {
+ try {
+ await client.requestBody(
+ "PUT",
+ `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}/labels`,
+ { labels: req.labels },
+ );
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+/**
+ * Replace the issue's assignee set. GitHub has no single "replace assignees"
+ * call, but the `assignees` array on PATCH /issues/{n} replaces the set
+ * atomically. Non-collaborator logins are silently dropped by GitHub (no 422);
+ * the renderer re-fetches afterward to show the authoritative set.
+ */
+export async function setIssueAssignees(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { number: number; assignees: string[] },
+): Promise {
+ try {
+ await client.requestBody("PATCH", `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}`, {
+ assignees: req.assignees,
+ });
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
+
+/**
+ * Set or clear the issue's milestone (PATCH /issues/{n}). `milestone` is the
+ * milestone number to assign, or `null` to remove it — GitHub accepts a literal
+ * `null` on this field to clear it, which is exactly what the picker's "No
+ * milestone" choice sends.
+ */
+export async function setMilestone(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ req: { number: number; milestone: number | null },
+): Promise {
+ try {
+ await client.requestBody("PATCH", `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}`, {
+ milestone: req.milestone,
+ });
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: errMessage(err) };
+ }
+}
diff --git a/apps/desktop/src/main/github/notifications.ts b/apps/desktop/src/main/github/notifications.ts
new file mode 100644
index 0000000..2369497
--- /dev/null
+++ b/apps/desktop/src/main/github/notifications.ts
@@ -0,0 +1,130 @@
+// The Notifications section's GitHub logic (the user's inbox). Runs in the
+// Electron MAIN process. Unlike the repo-scoped sections (PRs / Issues /
+// Releases), the GitHub Activity/Notifications API is ACCOUNT-scoped — the
+// /notifications endpoints take no owner/repo — so these functions take only
+// the client (+ args). main.ts invokes them via `github.withClient(...)`.
+//
+// All three endpoints are REST-only (the Activity API has no GraphQL form). The
+// OAuth token already carries the `notifications` scope GET /notifications,
+// PATCH /notifications/threads/{id}, and PUT /notifications require, so no new
+// scope is needed; the existing `request`/`requestBody` primitives set Bearer.
+
+import { GitHubClient, enc } from "../githubClient";
+import type { NotificationActionResult, NotificationThread } from "../../shared/ipc";
+
+/** Options for the inbox listing (mirrors the IPC request shape). */
+export interface ListNotificationsOptions {
+ /** Include already-read threads (GET /notifications?all=true). */
+ all?: boolean;
+ /** Restrict to threads the user is directly participating in. */
+ participating?: boolean;
+}
+
+/**
+ * The user's notification inbox across every watched repo. A READ method, so it
+ * THROWS on auth / rate-limit / network failure — the renderer surfaces a real
+ * error state + Retry rather than a misleading "inbox zero". Capped at 50 (no
+ * "load more" in v1, matching the other list views).
+ */
+export async function listNotifications(
+ client: GitHubClient,
+ opts: ListNotificationsOptions = {},
+): Promise {
+ const qs = new URLSearchParams();
+ if (opts.all) qs.set("all", "true");
+ if (opts.participating) qs.set("participating", "true");
+ qs.set("per_page", "50");
+ const raw = await client.request("GET", `/notifications?${qs.toString()}`);
+ return raw.map(mapNotification);
+}
+
+/**
+ * Mark a single thread as read (GitHub also stops surfacing it as unread). A
+ * MUTATION, so it returns the `{ ok, message }` result shape and never throws —
+ * the renderer toasts. PATCH .../threads/{id} returns 205 with no body; the
+ * client's `requestBody` only checks `res.ok`, which 2xx satisfies.
+ */
+export async function markNotificationRead(
+ client: GitHubClient,
+ id: string,
+): Promise {
+ try {
+ await client.requestBody("PATCH", `/notifications/threads/${enc(id)}`, {});
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+/**
+ * Mark EVERY notification in the user's inbox as read. PUT /notifications
+ * returns 202 (accepted; processed async on GitHub's side), so a re-list right
+ * after may briefly still show threads — the next refresh reconciles.
+ */
+export async function markAllNotificationsRead(
+ client: GitHubClient,
+): Promise {
+ try {
+ await client.requestBody("PUT", "/notifications", { read: true });
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+// ── Raw API shapes + mappers ─────────────────────────────────────────────────
+
+interface RawNotificationOwner {
+ avatar_url?: string | null;
+}
+interface RawNotificationSubject {
+ title: string;
+ type: string;
+ url: string | null;
+ latest_comment_url: string | null;
+}
+interface RawNotificationRepository {
+ full_name: string;
+ html_url: string;
+ owner?: RawNotificationOwner | null;
+}
+interface RawNotification {
+ id: string;
+ unread: boolean;
+ reason: string;
+ updated_at: string;
+ subject: RawNotificationSubject;
+ repository: RawNotificationRepository;
+}
+
+function mapNotification(n: RawNotification): NotificationThread {
+ return {
+ id: n.id,
+ title: n.subject?.title ?? "(untitled)",
+ type: n.subject?.type ?? "",
+ reason: n.reason ?? "",
+ repo: n.repository?.full_name ?? "",
+ repoAvatarUrl: n.repository?.owner?.avatar_url ?? null,
+ updatedAt: n.updated_at ?? "",
+ unread: n.unread ?? false,
+ htmlUrl: subjectHtmlUrl(n),
+ };
+}
+
+/**
+ * GitHub's notification subject `url` is an API url
+ * (api.github.com/repos/o/r/pulls/123) with no `html_url`. Rewrite pulls/issues
+ * to a github.com web url; Releases / Commits / Discussions lack a clean
+ * numbered subject url, so fall back to the repository's html_url.
+ */
+function subjectHtmlUrl(n: RawNotification): string {
+ const api = n.subject?.url ?? "";
+ if (api) {
+ const m = api.match(/repos\/([^/]+)\/([^/]+)\/(pulls|issues)\/(\d+)/);
+ if (m) {
+ const kind = m[3] === "pulls" ? "pull" : "issues";
+ return `https://github.com/${m[1]}/${m[2]}/${kind}/${m[4]}`;
+ }
+ }
+ return n.repository?.html_url ?? "";
+}
diff --git a/apps/desktop/src/main/github/orgs.ts b/apps/desktop/src/main/github/orgs.ts
new file mode 100644
index 0000000..c4b85d5
--- /dev/null
+++ b/apps/desktop/src/main/github/orgs.ts
@@ -0,0 +1,123 @@
+// The Organizations section's GitHub logic (main process). Four pure, read-only
+// REST list calls — orgs the signed-in user belongs to, plus each org's repos,
+// teams, and members. These are USER-scoped (not repo-scoped), so the functions
+// take only the client; main.ts invokes them via `github.withClient((c) => …)`.
+//
+// All four THROW on API error (the client's request() throws a clean Error via
+// toError(): 401 → token invalid, 403 → permissions/rate-limit, 404 → not found),
+// so the renderer paints an errorState + Retry rather than a misleading empty
+// list. There are no mutations here, so the {ok,changed,message} result shape is
+// not used anywhere in this module.
+
+import { GitHubClient, enc } from "../githubClient";
+import type { OrgInfo, OrgMember, OrgRepo, OrgTeam } from "../../shared/ipc";
+
+// ── Raw GitHub shapes (only the fields we map) ────────────────────────────────
+
+/** `/user/orgs` returns the SHORT org object: no `html_url`/`name`, so we
+ * synthesize the profile URL from the login in the mapper. */
+interface RawOrg {
+ login: string;
+ name?: string | null;
+ avatar_url?: string;
+ description?: string | null;
+}
+interface RawRepo {
+ name: string;
+ full_name: string;
+ html_url: string;
+ description?: string | null;
+ private?: boolean;
+ fork?: boolean;
+ archived?: boolean;
+ language?: string | null;
+ stargazers_count?: number;
+ pushed_at?: string;
+}
+interface RawTeam {
+ name: string;
+ slug: string;
+ description?: string | null;
+ privacy?: string;
+ html_url?: string;
+}
+interface RawMember {
+ login: string;
+ avatar_url?: string;
+ html_url?: string;
+}
+
+// ── Raw → public mappers ──────────────────────────────────────────────────────
+
+function mapOrg(o: RawOrg): OrgInfo {
+ return {
+ login: o.login,
+ name: o.name ?? null,
+ avatarUrl: o.avatar_url ?? null,
+ description: o.description ?? null,
+ htmlUrl: `https://github.com/${o.login}`,
+ };
+}
+function mapRepo(r: RawRepo): OrgRepo {
+ return {
+ name: r.name,
+ fullName: r.full_name,
+ htmlUrl: r.html_url,
+ description: r.description ?? null,
+ private: r.private ?? false,
+ fork: r.fork ?? false,
+ archived: r.archived ?? false,
+ language: r.language ?? null,
+ stargazersCount: r.stargazers_count ?? 0,
+ pushedAt: r.pushed_at ?? "",
+ };
+}
+function mapTeam(t: RawTeam): OrgTeam {
+ return {
+ name: t.name,
+ slug: t.slug,
+ description: t.description ?? null,
+ privacy: t.privacy ?? "",
+ htmlUrl: t.html_url ?? "",
+ };
+}
+function mapMember(m: RawMember): OrgMember {
+ return {
+ login: m.login,
+ avatarUrl: m.avatar_url ?? null,
+ htmlUrl: m.html_url ?? `https://github.com/${m.login}`,
+ };
+}
+
+// ── Read functions (user-scoped; throw on error) ─────────────────────────────
+
+/** Orgs the signed-in user has visible membership in. Orgs that hide the user's
+ * membership won't appear — expected GitHub behavior. */
+export async function listOrgs(client: GitHubClient): Promise {
+ const raw = await client.request("GET", `/user/orgs?per_page=100`);
+ return raw.map(mapOrg);
+}
+
+/** An org's repositories, most-recently-pushed first. Private repos appear when
+ * the OAuth token also carries `repo`; needs no extra scope of its own. */
+export async function listOrgRepos(client: GitHubClient, org: string): Promise {
+ const raw = await client.request(
+ "GET",
+ `/orgs/${enc(org)}/repos?sort=pushed&direction=desc&per_page=100`,
+ );
+ return raw.map(mapRepo);
+}
+
+/** An org's teams. Requires `read:org` + org membership; non-members get a 403
+ * that surfaces as the renderer's errorState + Retry. */
+export async function listOrgTeams(client: GitHubClient, org: string): Promise {
+ const raw = await client.request("GET", `/orgs/${enc(org)}/teams?per_page=100`);
+ return raw.map(mapTeam);
+}
+
+/** An org's members, per the org's visibility. A non-owner may only see public
+ * members → can be empty even for large orgs; a restricted list yields a 403. */
+export async function listOrgMembers(client: GitHubClient, org: string): Promise {
+ const raw = await client.request("GET", `/orgs/${enc(org)}/members?per_page=100`);
+ return raw.map(mapMember);
+}
diff --git a/apps/desktop/src/main/github/projects.ts b/apps/desktop/src/main/github/projects.ts
new file mode 100644
index 0000000..86bdad0
--- /dev/null
+++ b/apps/desktop/src/main/github/projects.ts
@@ -0,0 +1,238 @@
+// Projects v2 (the GitHub "Projects" board) for the desktop app's GitHub section.
+//
+// Projects v2 has NO REST API — everything here goes through the client's
+// `graphql()` primitive, which already throws (via toError) on HTTP failure
+// AND on `json.errors[0].message`. So the READ functions carry NO local
+// try/catch: scope / rate-limit / network errors propagate to the renderer's
+// errorState (the throw-on-read convention). The MUTATIONS stay wrapped and
+// return a CommitActionResult-shaped object ({ ok, changed, message }).
+//
+// Repo-scoped reads take (client, owner, repo); the board read + the two
+// mutations act on opaque GraphQL node ids, so they take (client, …ids). main.ts
+// drives all of them through `github.withRepo((c, o, r) => …)`, which already
+// guards the not-connected / not-on-github.com cases before we are reached.
+
+import { GitHubClient } from "../githubClient";
+import type { CommitActionResult, ProjectBoard, ProjectInfo } from "../../shared/ipc";
+
+// ── Raw GraphQL shapes (this module owns its own Raw* interfaces + mappers) ────
+
+interface RawProjectsData {
+ repository?: {
+ projectsV2?: {
+ nodes?: ({
+ id: string;
+ number: number;
+ title: string;
+ shortDescription?: string | null;
+ url: string;
+ closed: boolean;
+ updatedAt?: string | null;
+ items?: { totalCount: number } | null;
+ } | null)[];
+ } | null;
+ } | null;
+}
+
+interface RawBoardField {
+ id: string;
+ name: string;
+ options?: ({ id: string; name: string; color?: string | null } | null)[] | null;
+}
+
+interface RawBoardContent {
+ __typename?: string;
+ number?: number | null;
+ title?: string | null;
+ url?: string | null;
+ state?: string | null;
+ author?: { login?: string | null } | null;
+}
+
+interface RawBoardItem {
+ id: string;
+ updatedAt?: string | null;
+ type?: string | null;
+ fieldValueByName?: { optionId?: string | null; name?: string | null } | null;
+ content?: RawBoardContent | null;
+}
+
+interface RawBoardData {
+ node?: {
+ field?: RawBoardField | null;
+ items?: { nodes?: (RawBoardItem | null)[] | null } | null;
+ } | null;
+}
+
+// ── Reads (THROW on error) ────────────────────────────────────────────────────
+
+/**
+ * The repo's GitHub Projects (v2), newest-updated first. Capped at the 20 most
+ * recently touched — the same magnitude as the PR/issue lists. Errors propagate.
+ */
+export async function listProjects(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+): Promise {
+ const data = await client.graphql(
+ `query($owner:String!,$repo:String!){
+ repository(owner:$owner,name:$repo){
+ projectsV2(first:20,orderBy:{field:UPDATED_AT,direction:DESC}){
+ nodes{ id number title shortDescription url closed updatedAt items{totalCount} }
+ }
+ }
+ }`,
+ { owner, repo },
+ );
+ const nodes = data?.repository?.projectsV2?.nodes ?? [];
+ return nodes
+ .filter((p): p is NonNullable => !!p)
+ .map((p) => ({
+ id: p.id,
+ number: p.number,
+ title: p.title,
+ shortDescription: p.shortDescription ?? "",
+ url: p.url,
+ itemCount: p.items?.totalCount ?? 0,
+ closed: p.closed,
+ updatedAt: p.updatedAt ?? "",
+ }));
+}
+
+/**
+ * A project's board: its "Status" single-select field (the columns) plus every
+ * item (cards) with its content + current Status value. Items are paged to the
+ * first 100, ordered by board POSITION so columns read top-to-bottom as they do
+ * on github.com. Errors propagate.
+ *
+ * The board read acts on an opaque ProjectV2 node id, so it ignores owner/repo —
+ * they're accepted to keep the (client, owner, repo, …args) shape main.ts wires
+ * through `github.withRepo`.
+ */
+export async function getProjectBoard(
+ client: GitHubClient,
+ _owner: string,
+ _repo: string,
+ projectId: string,
+): Promise {
+ const data = await client.graphql(
+ `query($id:ID!){
+ node(id:$id){
+ ... on ProjectV2 {
+ field(name:"Status"){
+ ... on ProjectV2SingleSelectField {
+ id name options{ id name color }
+ }
+ }
+ items(first:100,orderBy:{field:POSITION,direction:ASC}){
+ nodes{
+ id updatedAt type
+ fieldValueByName(name:"Status"){
+ ... on ProjectV2ItemFieldSingleSelectValue { optionId name }
+ }
+ content{
+ __typename
+ ... on Issue { number title url state author{login} }
+ ... on PullRequest { number title url state author{login} }
+ ... on DraftIssue { title }
+ }
+ }
+ }
+ }
+ }
+ }`,
+ { id: projectId },
+ );
+ const proj = data?.node;
+ const rawField = proj?.field;
+ const field: ProjectBoard["field"] = rawField
+ ? {
+ id: rawField.id,
+ name: rawField.name,
+ options: (rawField.options ?? [])
+ .filter((o): o is NonNullable => !!o)
+ .map((o) => ({ id: o.id, name: o.name, color: o.color ?? "" })),
+ }
+ : null;
+ const items = (proj?.items?.nodes ?? [])
+ .filter((n): n is RawBoardItem => !!n && !!n.content)
+ .map((n) => {
+ const c = n.content as RawBoardContent;
+ return {
+ id: n.id,
+ type: n.type ?? "",
+ title: c.title ?? "(untitled)",
+ number: typeof c.number === "number" ? c.number : null,
+ state: c.state ?? "",
+ url: c.url ?? null,
+ author: c.author?.login ?? "",
+ statusOptionId: n.fieldValueByName?.optionId ?? null,
+ statusName: n.fieldValueByName?.name ?? "",
+ updatedAt: n.updatedAt ?? "",
+ };
+ });
+ return { field, items };
+}
+
+// ── Mutations (return { ok, changed, message }) ───────────────────────────────
+
+/**
+ * Move an item to a Status option, or clear its Status when `optionId` is null.
+ * Clearing uses a different mutation (clearProjectV2ItemFieldValue) than setting
+ * (updateProjectV2ItemFieldValue). Needs the WRITE `project` scope; without it
+ * GitHub returns a 403 whose message we surface verbatim (no crash).
+ */
+export async function moveProjectItem(
+ client: GitHubClient,
+ _owner: string,
+ _repo: string,
+ req: { projectId: string; itemId: string; fieldId: string; optionId: string | null },
+): Promise {
+ try {
+ if (req.optionId === null) {
+ await client.graphql<{ clearProjectV2ItemFieldValue?: { clientMutationId?: string | null } }>(
+ `mutation($p:ID!,$i:ID!,$f:ID!){
+ clearProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:$f}){ clientMutationId }
+ }`,
+ { p: req.projectId, i: req.itemId, f: req.fieldId },
+ );
+ } else {
+ await client.graphql<{ updateProjectV2ItemFieldValue?: { projectV2Item?: { id: string } | null } }>(
+ `mutation($p:ID!,$i:ID!,$f:ID!,$o:String!){
+ updateProjectV2ItemFieldValue(input:{
+ projectId:$p,itemId:$i,fieldId:$f,value:{singleSelectOptionId:$o}
+ }){ projectV2Item{ id } }
+ }`,
+ { p: req.projectId, i: req.itemId, f: req.fieldId, o: req.optionId },
+ );
+ }
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+/**
+ * Add an existing issue/PR (by its content node id) to a project. v1.1 scaffold:
+ * fully wired end to end so a future "Add item" affordance only needs a content
+ * id. Needs the WRITE `project` scope.
+ */
+export async function addProjectItem(
+ client: GitHubClient,
+ _owner: string,
+ _repo: string,
+ req: { projectId: string; contentId: string },
+): Promise {
+ try {
+ await client.graphql<{ addProjectV2ItemById?: { item?: { id: string } | null } }>(
+ `mutation($p:ID!,$c:ID!){
+ addProjectV2ItemById(input:{projectId:$p,contentId:$c}){ item{ id } }
+ }`,
+ { p: req.projectId, c: req.contentId },
+ );
+ return { ok: true, changed: true };
+ } catch (err) {
+ return { ok: false, changed: false, message: err instanceof Error ? err.message : String(err) };
+ }
+}
diff --git a/apps/desktop/src/main/github/prs.ts b/apps/desktop/src/main/github/prs.ts
new file mode 100644
index 0000000..d39a700
--- /dev/null
+++ b/apps/desktop/src/main/github/prs.ts
@@ -0,0 +1,651 @@
+// GitHub pull-request write actions + the Create-PR support reads.
+//
+// These standalone functions are the section's GitHub logic. They are invoked
+// from main.ts through the bridge's `withRepo` helper, which supplies an
+// authenticated `GitHubClient` plus the resolved owner/repo. Following the
+// section convention:
+// • READ helpers (branches / reviewers) THROW on a real API error so the
+// renderer can surface an errorState; they only degrade to a benign empty
+// result for the "no collaborator access" sub-case, where a free-text
+// fallback is the right UX.
+// • MUTATIONS never throw: they return a CommitActionResult-shaped object
+// ({ ok, changed, message }). `changed` is ALWAYS false here — none of these
+// PR API writes touch the local working tree (unlike pr:checkout), so the
+// graph/sync widgets must NOT refresh off them.
+//
+// We reuse the client's PUBLIC primitives (request / requestBody / graphql) and
+// the shared `enc` / `mapUser` helpers; the Raw* shapes + mappers we need that
+// the client keeps private (RawPull/mapPull) are redefined locally so this
+// module is self-contained.
+
+import { GitHubClient, enc, mapUser, RawUser } from "../githubClient";
+import type {
+ BranchRef,
+ CommitActionResult,
+ CreatePrRequest,
+ FileDiff,
+ PrPrefill,
+ PrReviewComment,
+ PrReviewRequest,
+ PrReviewThread,
+ PullRequest,
+ RepoCollaborator,
+ RepoLabel,
+} from "../../shared/ipc";
+
+// ── Raw API shapes (snake_case, GitHub REST) ──────────────────────────────────
+
+interface RawRef {
+ ref: string;
+ sha: string;
+}
+interface RawPull {
+ number: number;
+ title: string;
+ body: string | null;
+ state: string;
+ draft?: boolean;
+ html_url: string;
+ user: RawUser | null;
+ created_at: string;
+ updated_at: string;
+ head: RawRef;
+ base: RawRef;
+ labels?: { name: string; color: string }[];
+ comments?: number;
+ additions?: number;
+ deletions?: number;
+ changed_files?: number;
+}
+interface RawBranch {
+ name: string;
+}
+interface RawRepoMeta {
+ default_branch?: string;
+}
+interface RawLabel {
+ name: string;
+ color: string;
+ description: string | null;
+}
+/** The base64 file-contents payload from GET /contents/{path}. */
+interface RawContents {
+ content?: string;
+ encoding?: string;
+ /** Bytes; present for blobs. GitHub stops inlining content for very large files. */
+ size?: number;
+}
+
+function mapPull(p: RawPull): PullRequest {
+ return {
+ number: p.number,
+ title: p.title,
+ body: p.body,
+ state: p.state,
+ draft: p.draft ?? false,
+ htmlUrl: p.html_url,
+ user: mapUser(p.user),
+ createdAt: p.created_at,
+ updatedAt: p.updated_at,
+ head: { ref: p.head.ref, sha: p.head.sha },
+ base: { ref: p.base.ref, sha: p.base.sha },
+ labels: (p.labels ?? []).map((l) => ({ name: l.name, color: l.color })),
+ comments: p.comments,
+ additions: p.additions,
+ deletions: p.deletions,
+ changedFiles: p.changed_files,
+ };
+}
+
+function errMessage(err: unknown): string {
+ return err instanceof Error ? err.message : String(err);
+}
+
+/** Resolve a PR's base + head commit SHAs (anchors for diffs + review comments). */
+async function prRefs(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ n: number,
+): Promise<{ baseSha: string; headSha: string }> {
+ const p = await client.request("GET", `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}`);
+ return { baseSha: p.base.sha, headSha: p.head.sha };
+}
+
+/**
+ * The text of a file at a given ref via the Contents API, or "" when the path
+ * doesn't exist on that side (404 = added/removed) or can't be read as text.
+ *
+ * • 404 → "" (the file was added on head / deleted on base — that side is empty).
+ * • Over GitHub's ~1MB inline cap (no `content` returned) → a short placeholder
+ * so the diff degrades gracefully instead of throwing.
+ * • A real network / auth error still throws (the caller is a READ → surfaces an
+ * errorState).
+ */
+async function fileTextAt(
+ client: GitHubClient,
+ owner: string,
+ repo: string,
+ path: string,
+ ref: string,
+): Promise {
+ // Hard cap: never decode more than ~2MB of base64 into the renderer.
+ const MAX_BYTES = 2 * 1024 * 1024;
+ let raw: RawContents;
+ try {
+ raw = await client.request