@thevinci/web 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -0
- package/bin/cli-entry.js +55 -0
- package/bin/cli-output.js +145 -0
- package/bin/cli.js +4887 -0
- package/bin/cli.test.js +64 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +528 -0
- package/dist/assets/JsonTreeView-CSm9OzXG.js +1 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/MarkdownRendererImpl-DensKOLc.js +6 -0
- package/dist/assets/MultiRunWindow-Bo7THayo.js +1 -0
- package/dist/assets/OnboardingScreen-BDqmzTVR.js +2 -0
- package/dist/assets/SettingsWindow-coz__Ykw.js +1 -0
- package/dist/assets/TerminalView-DrZ-i3Dr.js +1 -0
- package/dist/assets/ToolOutputDialog-Eglzslt3.js +16 -0
- package/dist/assets/es-4o9ciP61.js +15 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-DLTDToSP.css +1 -0
- package/dist/assets/index-DgiFEKGN.js +1 -0
- package/dist/assets/ko-B20imCHE.js +15 -0
- package/dist/assets/main-BV3KOtdA.css +1 -0
- package/dist/assets/main-CDKJj0sH.js +226 -0
- package/dist/assets/main-LC-PSNVM.js +2 -0
- package/dist/assets/miniChat-CQUiG_cr.js +2 -0
- package/dist/assets/modelPrefsAutoSave-Dm799vzR.js +6986 -0
- package/dist/assets/pl-DQJ7LSzj.js +15 -0
- package/dist/assets/pt-BR-OmjHUz9y.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-CARbeW0G.js +2 -0
- package/dist/assets/uk-BNFxOlO4.js +15 -0
- package/dist/assets/vendor--DBfsbEis.css +1 -0
- package/dist/assets/vendor-.bun-B9l0ZNi2.js +4094 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/wasmSttWorker-Dtlxac_K.js +1 -0
- package/dist/assets/wasmSttWorker-oo7Dm_jy.js +1806 -0
- package/dist/assets/worker-CbT6TVo7.js +155 -0
- package/dist/assets/zh-CN-C6T-Ac7F.js +15 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +528 -0
- package/dist/index.html +607 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.png +0 -0
- package/dist/logo-dark-512x512.svg +528 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.png +0 -0
- package/dist/logo-light-512x512.svg +528 -0
- package/dist/mini-chat.html +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +21 -0
- package/dist/sw.js +1 -0
- package/package.json +118 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +528 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +528 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.png +0 -0
- package/public/logo-dark-512x512.svg +528 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.png +0 -0
- package/public/logo-light-512x512.svg +528 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +21 -0
- package/server/TERMINAL_WS_PROTOCOL.md +48 -0
- package/server/index.d.ts +39 -0
- package/server/index.js +1311 -0
- package/server/lib/cloudflare-tunnel.js +650 -0
- package/server/lib/event-stream/DOCUMENTATION.md +61 -0
- package/server/lib/event-stream/directory-ws-bridge.js +185 -0
- package/server/lib/event-stream/global-hub.js +158 -0
- package/server/lib/event-stream/global-hub.test.js +140 -0
- package/server/lib/event-stream/global-ws-bridge.js +206 -0
- package/server/lib/event-stream/index.js +25 -0
- package/server/lib/event-stream/protocol.js +131 -0
- package/server/lib/event-stream/protocol.test.js +182 -0
- package/server/lib/event-stream/runtime.js +180 -0
- package/server/lib/event-stream/runtime.test.js +512 -0
- package/server/lib/event-stream/upstream-reader.js +226 -0
- package/server/lib/event-stream/upstream-reader.test.js +276 -0
- package/server/lib/fs/DOCUMENTATION.md +36 -0
- package/server/lib/fs/routes.js +1040 -0
- package/server/lib/fs/search.js +238 -0
- package/server/lib/git/DOCUMENTATION.md +152 -0
- package/server/lib/git/credentials.js +74 -0
- package/server/lib/git/identity-storage.js +112 -0
- package/server/lib/git/index.js +6 -0
- package/server/lib/git/routes.js +972 -0
- package/server/lib/git/service.js +3432 -0
- package/server/lib/git/service.test.js +39 -0
- package/server/lib/github/DOCUMENTATION.md +171 -0
- package/server/lib/github/auth.js +307 -0
- package/server/lib/github/device-flow.js +50 -0
- package/server/lib/github/index.js +24 -0
- package/server/lib/github/octokit.js +10 -0
- package/server/lib/github/pr-status.js +519 -0
- package/server/lib/github/repo/fork-detection.js +102 -0
- package/server/lib/github/repo/index.js +55 -0
- package/server/lib/github/routes.js +1560 -0
- package/server/lib/magic-prompts/routes.js +63 -0
- package/server/lib/magic-prompts/runtime.js +119 -0
- package/server/lib/notifications/DOCUMENTATION.md +122 -0
- package/server/lib/notifications/emitter-runtime.js +102 -0
- package/server/lib/notifications/index.js +4 -0
- package/server/lib/notifications/message.js +52 -0
- package/server/lib/notifications/message.test.js +34 -0
- package/server/lib/notifications/push-runtime.js +304 -0
- package/server/lib/notifications/routes.js +315 -0
- package/server/lib/notifications/runtime.js +566 -0
- package/server/lib/notifications/template-runtime.js +349 -0
- package/server/lib/notifications/template-runtime.test.js +26 -0
- package/server/lib/opencode/DOCUMENTATION.md +362 -0
- package/server/lib/opencode/agents.js +634 -0
- package/server/lib/opencode/auth-state-runtime.js +88 -0
- package/server/lib/opencode/auth.js +83 -0
- package/server/lib/opencode/bootstrap-runtime.js +131 -0
- package/server/lib/opencode/cli-entry-runtime.js +43 -0
- package/server/lib/opencode/cli-options.js +128 -0
- package/server/lib/opencode/commands.js +339 -0
- package/server/lib/opencode/config-entity-routes.js +370 -0
- package/server/lib/opencode/core-routes.js +500 -0
- package/server/lib/opencode/core-routes.test.js +26 -0
- package/server/lib/opencode/env-config.js +74 -0
- package/server/lib/opencode/env-keys.js +68 -0
- package/server/lib/opencode/env-runtime.js +1162 -0
- package/server/lib/opencode/env-runtime.test.js +116 -0
- package/server/lib/opencode/feature-routes-runtime.js +244 -0
- package/server/lib/opencode/hmr-state-runtime.js +85 -0
- package/server/lib/opencode/index.js +66 -0
- package/server/lib/opencode/lifecycle.js +1019 -0
- package/server/lib/opencode/lifecycle.test.js +240 -0
- package/server/lib/opencode/mcp.js +278 -0
- package/server/lib/opencode/network-runtime.js +104 -0
- package/server/lib/opencode/network-runtime.test.js +37 -0
- package/server/lib/opencode/opencode-resolution-runtime.js +71 -0
- package/server/lib/opencode/path-utils.js +100 -0
- package/server/lib/opencode/path-utils.test.js +71 -0
- package/server/lib/opencode/project-directory-runtime.js +124 -0
- package/server/lib/opencode/project-icon-routes.js +399 -0
- package/server/lib/opencode/project-icon-routes.test.js +107 -0
- package/server/lib/opencode/providers.js +96 -0
- package/server/lib/opencode/proxy.js +445 -0
- package/server/lib/opencode/pwa-manifest-routes.js +257 -0
- package/server/lib/opencode/pwa-manifest-routes.test.js +133 -0
- package/server/lib/opencode/routes.js +541 -0
- package/server/lib/opencode/server-startup-runtime.js +156 -0
- package/server/lib/opencode/server-utils-runtime.js +168 -0
- package/server/lib/opencode/server-utils-runtime.test.js +135 -0
- package/server/lib/opencode/session-runtime.js +356 -0
- package/server/lib/opencode/session-runtime.test.js +151 -0
- package/server/lib/opencode/settings-helpers.js +770 -0
- package/server/lib/opencode/settings-helpers.test.js +109 -0
- package/server/lib/opencode/settings-normalization-runtime.js +428 -0
- package/server/lib/opencode/settings-runtime.js +826 -0
- package/server/lib/opencode/settings-runtime.test.js +85 -0
- package/server/lib/opencode/shared.js +615 -0
- package/server/lib/opencode/shutdown-runtime.js +139 -0
- package/server/lib/opencode/shutdown-runtime.test.js +58 -0
- package/server/lib/opencode/skill-routes.js +701 -0
- package/server/lib/opencode/skills.js +548 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +130 -0
- package/server/lib/opencode/static-routes-runtime.js +65 -0
- package/server/lib/opencode/theme-runtime.js +167 -0
- package/server/lib/opencode/tunnel-auth.js +591 -0
- package/server/lib/opencode/tunnel-wiring-runtime.js +94 -0
- package/server/lib/opencode/vinci-routes.js +76 -0
- package/server/lib/opencode/watcher.js +115 -0
- package/server/lib/opencode/watcher.test.js +239 -0
- package/server/lib/preview/proxy-runtime.js +1333 -0
- package/server/lib/preview/proxy-runtime.test.js +144 -0
- package/server/lib/projects/project-config.js +567 -0
- package/server/lib/projects/project-config.test.js +175 -0
- package/server/lib/projects/project-id.js +13 -0
- package/server/lib/quota/DOCUMENTATION.md +58 -0
- package/server/lib/quota/index.js +25 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +113 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +168 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/minimax-cn-coding-plan.js +140 -0
- package/server/lib/quota/providers/minimax-coding-plan.js +139 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/ollama-cloud.js +112 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/providers/zhipuai-coding-plan.js +133 -0
- package/server/lib/quota/providers/zhipuai.js +114 -0
- package/server/lib/quota/routes.js +27 -0
- package/server/lib/quota/utils/auth.js +50 -0
- package/server/lib/quota/utils/formatters.js +85 -0
- package/server/lib/quota/utils/formatters.test.js +54 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +44 -0
- package/server/lib/scheduled-tasks/routes.js +235 -0
- package/server/lib/scheduled-tasks/runtime.js +773 -0
- package/server/lib/scheduled-tasks/runtime.test.js +100 -0
- package/server/lib/security/request-security.js +115 -0
- package/server/lib/session-folders/routes.js +63 -0
- package/server/lib/session-folders/routes.test.js +102 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
- package/server/lib/skills-catalog/cache.js +29 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +238 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +77 -0
- package/server/lib/skills-catalog/index.js +42 -0
- package/server/lib/skills-catalog/install.js +294 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +87 -0
- package/server/lib/terminal/DOCUMENTATION.md +76 -0
- package/server/lib/terminal/index.js +31 -0
- package/server/lib/terminal/output-replay-buffer.js +78 -0
- package/server/lib/terminal/output-replay-buffer.test.js +75 -0
- package/server/lib/terminal/runtime.js +850 -0
- package/server/lib/terminal/runtime.test.js +96 -0
- package/server/lib/terminal/terminal-ws-protocol.js +68 -0
- package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
- package/server/lib/text/DOCUMENTATION.md +35 -0
- package/server/lib/text/summarization.js +138 -0
- package/server/lib/text/summarization.test.js +34 -0
- package/server/lib/tts/DOCUMENTATION.md +146 -0
- package/server/lib/tts/base-url.js +62 -0
- package/server/lib/tts/capability-runtime.js +31 -0
- package/server/lib/tts/index.js +19 -0
- package/server/lib/tts/routes.js +261 -0
- package/server/lib/tts/routes.test.js +53 -0
- package/server/lib/tts/service.js +178 -0
- package/server/lib/tts/stt.js +75 -0
- package/server/lib/tunnels/DOCUMENTATION.md +18 -0
- package/server/lib/tunnels/index.js +166 -0
- package/server/lib/tunnels/managed-config.js +201 -0
- package/server/lib/tunnels/providers/cloudflare.js +260 -0
- package/server/lib/tunnels/registry.js +51 -0
- package/server/lib/tunnels/routes.js +605 -0
- package/server/lib/tunnels/types.js +219 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +38 -0
- package/server/lib/ui-auth/ui-auth.js +673 -0
- package/server/lib/ui-auth/ui-passkeys.js +545 -0
- package/server/opencode-proxy.test.js +151 -0
- package/server/proxy-headers.js +61 -0
- package/server/proxy-headers.test.js +58 -0
- package/server/sse-routes.test.js +152 -0
|
@@ -0,0 +1,1560 @@
|
|
|
1
|
+
const PR_STATUS_CACHE_TTL_MS = 90_000;
|
|
2
|
+
const PR_STATUS_CACHE_MAX_ENTRIES = 200;
|
|
3
|
+
const prStatusCache = new Map();
|
|
4
|
+
|
|
5
|
+
function getRequestedRepo(req) {
|
|
6
|
+
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
|
|
7
|
+
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
|
|
8
|
+
return owner && repo ? { owner, repo } : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function resolveRepoForRequest(octokit, directory, requestedRepo) {
|
|
12
|
+
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
13
|
+
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
14
|
+
if (!requestedRepo) {
|
|
15
|
+
return repo;
|
|
16
|
+
}
|
|
17
|
+
if (repo?.owner === requestedRepo.owner && repo?.repo === requestedRepo.repo) {
|
|
18
|
+
return requestedRepo;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
|
|
22
|
+
const network = await resolveRepoNetwork(octokit, directory).catch(() => null);
|
|
23
|
+
const allowed = Array.isArray(network)
|
|
24
|
+
? network.some((item) => item?.owner === requestedRepo.owner && item?.repo === requestedRepo.repo)
|
|
25
|
+
: false;
|
|
26
|
+
return allowed ? requestedRepo : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setPrStatusCache(key, data, fetchedAt) {
|
|
30
|
+
// Evict oldest entry when cache exceeds max size
|
|
31
|
+
if (prStatusCache.size >= PR_STATUS_CACHE_MAX_ENTRIES && !prStatusCache.has(key)) {
|
|
32
|
+
const oldest = prStatusCache.entries().next().value;
|
|
33
|
+
if (oldest) {
|
|
34
|
+
prStatusCache.delete(oldest[0]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
prStatusCache.set(key, { data, fetchedAt });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerGitHubRoutes(app) {
|
|
41
|
+
let githubLibraries = null;
|
|
42
|
+
const getGitHubLibraries = async () => {
|
|
43
|
+
if (!githubLibraries) {
|
|
44
|
+
githubLibraries = await import('./index.js');
|
|
45
|
+
}
|
|
46
|
+
return githubLibraries;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const getGitHubUserSummary = async (octokit) => {
|
|
50
|
+
const me = await octokit.rest.users.getAuthenticated();
|
|
51
|
+
|
|
52
|
+
let email = typeof me.data.email === 'string' ? me.data.email : null;
|
|
53
|
+
if (!email) {
|
|
54
|
+
try {
|
|
55
|
+
const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 });
|
|
56
|
+
const list = Array.isArray(emails?.data) ? emails.data : [];
|
|
57
|
+
const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string');
|
|
58
|
+
const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string');
|
|
59
|
+
email = primaryVerified?.email || anyVerified?.email || null;
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore (scope might be missing)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
login: me.data.login,
|
|
67
|
+
id: me.data.id,
|
|
68
|
+
avatarUrl: me.data.avatar_url,
|
|
69
|
+
name: typeof me.data.name === 'string' ? me.data.name : null,
|
|
70
|
+
email,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403;
|
|
75
|
+
const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404;
|
|
76
|
+
|
|
77
|
+
app.get('/api/github/auth/status', async (_req, res) => {
|
|
78
|
+
try {
|
|
79
|
+
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
|
80
|
+
const auth = getGitHubAuth();
|
|
81
|
+
const accounts = getGitHubAuthAccounts();
|
|
82
|
+
if (!auth?.accessToken) {
|
|
83
|
+
return res.json({ connected: false, accounts });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const octokit = getOctokitOrNull();
|
|
87
|
+
if (!octokit) {
|
|
88
|
+
return res.json({ connected: false, accounts });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let user = null;
|
|
92
|
+
try {
|
|
93
|
+
user = await getGitHubUserSummary(octokit);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (isGitHubAuthInvalid(error)) {
|
|
96
|
+
clearGitHubAuth();
|
|
97
|
+
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fallback = auth.user;
|
|
102
|
+
const mergedUser = user || fallback;
|
|
103
|
+
|
|
104
|
+
return res.json({
|
|
105
|
+
connected: true,
|
|
106
|
+
user: mergedUser,
|
|
107
|
+
scope: auth.scope,
|
|
108
|
+
accounts,
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Failed to get GitHub auth status:', error);
|
|
112
|
+
return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
app.post('/api/github/auth/start', async (_req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
|
|
119
|
+
const clientId = getGitHubClientId();
|
|
120
|
+
if (!clientId) {
|
|
121
|
+
return res.status(400).json({
|
|
122
|
+
error: 'GitHub OAuth client not configured. Set VINCI_GITHUB_CLIENT_ID.',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const scope = getGitHubScopes();
|
|
127
|
+
|
|
128
|
+
const payload = await startDeviceFlow({
|
|
129
|
+
clientId,
|
|
130
|
+
scope,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return res.json({
|
|
134
|
+
deviceCode: payload.device_code,
|
|
135
|
+
userCode: payload.user_code,
|
|
136
|
+
verificationUri: payload.verification_uri,
|
|
137
|
+
verificationUriComplete: payload.verification_uri_complete,
|
|
138
|
+
expiresIn: payload.expires_in,
|
|
139
|
+
interval: payload.interval,
|
|
140
|
+
scope,
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error('Failed to start GitHub device flow:', error);
|
|
144
|
+
return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
app.post('/api/github/auth/complete', async (req, res) => {
|
|
149
|
+
try {
|
|
150
|
+
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
|
151
|
+
const clientId = getGitHubClientId();
|
|
152
|
+
if (!clientId) {
|
|
153
|
+
return res.status(400).json({
|
|
154
|
+
error: 'GitHub OAuth client not configured. Set VINCI_GITHUB_CLIENT_ID.',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const deviceCode = typeof req.body?.deviceCode === 'string'
|
|
159
|
+
? req.body.deviceCode
|
|
160
|
+
: (typeof req.body?.device_code === 'string' ? req.body.device_code : '');
|
|
161
|
+
|
|
162
|
+
if (!deviceCode) {
|
|
163
|
+
return res.status(400).json({ error: 'deviceCode is required' });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const payload = await exchangeDeviceCode({ clientId, deviceCode });
|
|
167
|
+
|
|
168
|
+
if (payload?.error) {
|
|
169
|
+
return res.json({
|
|
170
|
+
connected: false,
|
|
171
|
+
status: payload.error,
|
|
172
|
+
error: payload.error_description || payload.error,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const accessToken = payload?.access_token;
|
|
177
|
+
if (!accessToken) {
|
|
178
|
+
return res.status(500).json({ error: 'Missing access_token from GitHub' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { Octokit } = await import('@octokit/rest');
|
|
182
|
+
const octokit = new Octokit({ auth: accessToken });
|
|
183
|
+
const user = await getGitHubUserSummary(octokit);
|
|
184
|
+
|
|
185
|
+
setGitHubAuth({
|
|
186
|
+
accessToken,
|
|
187
|
+
scope: typeof payload.scope === 'string' ? payload.scope : '',
|
|
188
|
+
tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer',
|
|
189
|
+
user,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return res.json({
|
|
193
|
+
connected: true,
|
|
194
|
+
user,
|
|
195
|
+
scope: typeof payload.scope === 'string' ? payload.scope : '',
|
|
196
|
+
accounts: getGitHubAuthAccounts(),
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.error('Failed to complete GitHub device flow:', error);
|
|
200
|
+
return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
app.post('/api/github/auth/activate', async (req, res) => {
|
|
205
|
+
try {
|
|
206
|
+
const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
|
207
|
+
const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : '';
|
|
208
|
+
if (!accountId) {
|
|
209
|
+
return res.status(400).json({ error: 'accountId is required' });
|
|
210
|
+
}
|
|
211
|
+
const activated = activateGitHubAuth(accountId);
|
|
212
|
+
if (!activated) {
|
|
213
|
+
return res.status(404).json({ error: 'GitHub account not found' });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const auth = getGitHubAuth();
|
|
217
|
+
const accounts = getGitHubAuthAccounts();
|
|
218
|
+
if (!auth?.accessToken) {
|
|
219
|
+
return res.json({ connected: false, accounts });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const octokit = getOctokitOrNull();
|
|
223
|
+
if (!octokit) {
|
|
224
|
+
return res.json({ connected: false, accounts });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let user = auth.user || null;
|
|
228
|
+
try {
|
|
229
|
+
user = await getGitHubUserSummary(octokit);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (isGitHubAuthInvalid(error)) {
|
|
232
|
+
clearGitHubAuth();
|
|
233
|
+
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return res.json({
|
|
238
|
+
connected: true,
|
|
239
|
+
user,
|
|
240
|
+
scope: auth.scope,
|
|
241
|
+
accounts,
|
|
242
|
+
});
|
|
243
|
+
} catch (error) {
|
|
244
|
+
console.error('Failed to activate GitHub account:', error);
|
|
245
|
+
return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
app.delete('/api/github/auth', async (_req, res) => {
|
|
250
|
+
try {
|
|
251
|
+
const { clearGitHubAuth } = await getGitHubLibraries();
|
|
252
|
+
const removed = clearGitHubAuth();
|
|
253
|
+
return res.json({ success: true, removed });
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.error('Failed to disconnect GitHub:', error);
|
|
256
|
+
return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
app.get('/api/github/me', async (_req, res) => {
|
|
261
|
+
try {
|
|
262
|
+
const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
|
|
263
|
+
const octokit = getOctokitOrNull();
|
|
264
|
+
if (!octokit) {
|
|
265
|
+
return res.status(401).json({ error: 'GitHub not connected' });
|
|
266
|
+
}
|
|
267
|
+
let user;
|
|
268
|
+
try {
|
|
269
|
+
user = await getGitHubUserSummary(octokit);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (isGitHubAuthInvalid(error)) {
|
|
272
|
+
clearGitHubAuth();
|
|
273
|
+
return res.status(401).json({ error: 'GitHub token expired or revoked' });
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
return res.json(user);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error('Failed to fetch GitHub user:', error);
|
|
280
|
+
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ================= GitHub PR APIs =================
|
|
285
|
+
|
|
286
|
+
app.get('/api/github/pr/status', async (req, res) => {
|
|
287
|
+
try {
|
|
288
|
+
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|
289
|
+
const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
|
|
290
|
+
const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin';
|
|
291
|
+
const force = req.query?.force === 'true' || req.query?.force === '1';
|
|
292
|
+
if (!directory || !branch) {
|
|
293
|
+
return res.status(400).json({ error: 'directory and branch are required' });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Check cache (skip when force=true to allow manual refresh bypass)
|
|
297
|
+
const cacheKey = `${directory}::${branch}::${remote}`;
|
|
298
|
+
const cached = prStatusCache.get(cacheKey);
|
|
299
|
+
if (!force && cached && Date.now() - cached.fetchedAt < PR_STATUS_CACHE_TTL_MS) {
|
|
300
|
+
return res.json(cached.data);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Intercept res.json to cache successful responses before sending
|
|
304
|
+
// Only caches responses with connected:true — error/edge-case responses are not cached
|
|
305
|
+
const originalJson = res.json.bind(res);
|
|
306
|
+
res.json = (data) => {
|
|
307
|
+
if (data && data.connected === true) {
|
|
308
|
+
setPrStatusCache(cacheKey, data, Date.now());
|
|
309
|
+
}
|
|
310
|
+
return originalJson(data);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries();
|
|
314
|
+
const octokit = getOctokitOrNull();
|
|
315
|
+
if (!octokit) {
|
|
316
|
+
return res.json({ connected: false });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const { resolveGitHubPrStatus } = await import('./pr-status.js');
|
|
320
|
+
const resolvedStatus = await resolveGitHubPrStatus({
|
|
321
|
+
octokit,
|
|
322
|
+
directory,
|
|
323
|
+
branch,
|
|
324
|
+
remoteName: remote,
|
|
325
|
+
});
|
|
326
|
+
const searchRepo = resolvedStatus.repo;
|
|
327
|
+
const first = resolvedStatus.pr;
|
|
328
|
+
if (!searchRepo) {
|
|
329
|
+
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null });
|
|
330
|
+
}
|
|
331
|
+
if (!first) {
|
|
332
|
+
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Enrich with mergeability fields
|
|
336
|
+
const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number });
|
|
337
|
+
const prData = prFull?.data;
|
|
338
|
+
if (!prData) {
|
|
339
|
+
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Checks summary: prefer check-runs (Actions), fallback to classic statuses.
|
|
343
|
+
let checks = null;
|
|
344
|
+
const sha = prData.head?.sha;
|
|
345
|
+
if (sha) {
|
|
346
|
+
try {
|
|
347
|
+
const runs = await octokit.rest.checks.listForRef({
|
|
348
|
+
owner: searchRepo.owner,
|
|
349
|
+
repo: searchRepo.repo,
|
|
350
|
+
ref: sha,
|
|
351
|
+
per_page: 100,
|
|
352
|
+
});
|
|
353
|
+
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
|
|
354
|
+
if (checkRuns.length > 0) {
|
|
355
|
+
const counts = { success: 0, failure: 0, pending: 0 };
|
|
356
|
+
for (const run of checkRuns) {
|
|
357
|
+
const status = run?.status;
|
|
358
|
+
const conclusion = run?.conclusion;
|
|
359
|
+
if (status === 'queued' || status === 'in_progress') {
|
|
360
|
+
counts.pending += 1;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (!conclusion) {
|
|
364
|
+
counts.pending += 1;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
|
368
|
+
counts.success += 1;
|
|
369
|
+
} else {
|
|
370
|
+
counts.failure += 1;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const total = counts.success + counts.failure + counts.pending;
|
|
374
|
+
const state = counts.failure > 0
|
|
375
|
+
? 'failure'
|
|
376
|
+
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|
377
|
+
checks = { state, total, ...counts };
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
// ignore and fall back
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!checks) {
|
|
384
|
+
try {
|
|
385
|
+
const combined = await octokit.rest.repos.getCombinedStatusForRef({
|
|
386
|
+
owner: searchRepo.owner,
|
|
387
|
+
repo: searchRepo.repo,
|
|
388
|
+
ref: sha,
|
|
389
|
+
});
|
|
390
|
+
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
|
|
391
|
+
const counts = { success: 0, failure: 0, pending: 0 };
|
|
392
|
+
statuses.forEach((s) => {
|
|
393
|
+
if (s.state === 'success') counts.success += 1;
|
|
394
|
+
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
|
395
|
+
else if (s.state === 'pending') counts.pending += 1;
|
|
396
|
+
});
|
|
397
|
+
const total = counts.success + counts.failure + counts.pending;
|
|
398
|
+
const state = counts.failure > 0
|
|
399
|
+
? 'failure'
|
|
400
|
+
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|
401
|
+
checks = { state, total, ...counts };
|
|
402
|
+
} catch {
|
|
403
|
+
checks = null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Permission check (best-effort)
|
|
409
|
+
let canMerge = false;
|
|
410
|
+
try {
|
|
411
|
+
const auth = getGitHubAuth();
|
|
412
|
+
const username = auth?.user?.login;
|
|
413
|
+
if (username) {
|
|
414
|
+
const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({
|
|
415
|
+
owner: searchRepo.owner,
|
|
416
|
+
repo: searchRepo.repo,
|
|
417
|
+
username,
|
|
418
|
+
});
|
|
419
|
+
const level = perm?.data?.permission;
|
|
420
|
+
canMerge = level === 'admin' || level === 'maintain' || level === 'write';
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
canMerge = false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const isMerged = Boolean(prData.merged || prData.merged_at);
|
|
427
|
+
const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
|
|
428
|
+
|
|
429
|
+
return res.json({
|
|
430
|
+
connected: true,
|
|
431
|
+
repo: searchRepo,
|
|
432
|
+
branch,
|
|
433
|
+
pr: {
|
|
434
|
+
number: prData.number,
|
|
435
|
+
title: prData.title,
|
|
436
|
+
body: prData.body || '',
|
|
437
|
+
url: prData.html_url,
|
|
438
|
+
state: mergedState,
|
|
439
|
+
draft: Boolean(prData.draft),
|
|
440
|
+
base: prData.base?.ref,
|
|
441
|
+
head: prData.head?.ref,
|
|
442
|
+
headSha: prData.head?.sha,
|
|
443
|
+
mergeable: prData.mergeable,
|
|
444
|
+
mergeableState: prData.mergeable_state,
|
|
445
|
+
},
|
|
446
|
+
checks,
|
|
447
|
+
canMerge,
|
|
448
|
+
defaultBranch: resolvedStatus.defaultBranch ?? null,
|
|
449
|
+
resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null,
|
|
450
|
+
});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error?.status === 401) {
|
|
453
|
+
const { clearGitHubAuth } = await getGitHubLibraries();
|
|
454
|
+
clearGitHubAuth();
|
|
455
|
+
return res.json({ connected: false });
|
|
456
|
+
}
|
|
457
|
+
if (isGitHubResourceUnavailable(error)) {
|
|
458
|
+
return res.json({
|
|
459
|
+
connected: true,
|
|
460
|
+
repo: null,
|
|
461
|
+
branch: typeof req.query?.branch === 'string' ? req.query.branch.trim() : '',
|
|
462
|
+
pr: null,
|
|
463
|
+
checks: null,
|
|
464
|
+
canMerge: false,
|
|
465
|
+
defaultBranch: null,
|
|
466
|
+
resolvedRemoteName: null,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
console.error('Failed to load GitHub PR status:', error);
|
|
470
|
+
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' });
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
app.post('/api/github/pr/create', async (req, res) => {
|
|
475
|
+
try {
|
|
476
|
+
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|
477
|
+
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
|
478
|
+
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
|
|
479
|
+
const requestedBase = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
|
|
480
|
+
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
|
|
481
|
+
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
|
|
482
|
+
// remote = target repo (where PR is created, e.g., 'upstream' for forks)
|
|
483
|
+
const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin';
|
|
484
|
+
// headRemote = source repo (where head branch lives, e.g., 'origin' for forks)
|
|
485
|
+
const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : '';
|
|
486
|
+
// targetRepo = explicit target repo (alternative to remote, for auto-detected upstream)
|
|
487
|
+
const targetRepo = req.body?.targetRepo && typeof req.body.targetRepo.owner === 'string' && typeof req.body.targetRepo.repo === 'string'
|
|
488
|
+
? { owner: req.body.targetRepo.owner.trim(), repo: req.body.targetRepo.repo.trim() }
|
|
489
|
+
: null;
|
|
490
|
+
if (!directory || !title || !head || !requestedBase) {
|
|
491
|
+
return res.status(400).json({ error: 'directory, title, head, base are required' });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
495
|
+
const octokit = getOctokitOrNull();
|
|
496
|
+
if (!octokit) {
|
|
497
|
+
return res.status(401).json({ error: 'GitHub not connected' });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
501
|
+
let repo;
|
|
502
|
+
if (targetRepo) {
|
|
503
|
+
repo = targetRepo;
|
|
504
|
+
} else {
|
|
505
|
+
const resolved = await resolveGitHubRepoFromDirectory(directory, remote);
|
|
506
|
+
repo = resolved.repo;
|
|
507
|
+
}
|
|
508
|
+
if (!repo) {
|
|
509
|
+
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const normalizeBranchRef = (value, remoteNames = new Set()) => {
|
|
513
|
+
if (!value) {
|
|
514
|
+
return value;
|
|
515
|
+
}
|
|
516
|
+
let normalized = value.trim();
|
|
517
|
+
if (normalized.startsWith('refs/heads/')) {
|
|
518
|
+
normalized = normalized.substring('refs/heads/'.length);
|
|
519
|
+
}
|
|
520
|
+
if (normalized.startsWith('heads/')) {
|
|
521
|
+
normalized = normalized.substring('heads/'.length);
|
|
522
|
+
}
|
|
523
|
+
if (normalized.startsWith('remotes/')) {
|
|
524
|
+
normalized = normalized.substring('remotes/'.length);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const slashIndex = normalized.indexOf('/');
|
|
528
|
+
if (slashIndex > 0) {
|
|
529
|
+
const maybeRemote = normalized.slice(0, slashIndex);
|
|
530
|
+
if (remoteNames.has(maybeRemote)) {
|
|
531
|
+
const withoutRemotePrefix = normalized.slice(slashIndex + 1).trim();
|
|
532
|
+
if (withoutRemotePrefix) {
|
|
533
|
+
normalized = withoutRemotePrefix;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return normalized;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// Determine the source remote for the head branch
|
|
542
|
+
// Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin
|
|
543
|
+
let sourceRemote = headRemote;
|
|
544
|
+
const { getStatus, getRemotes } = await import('../git/index.js');
|
|
545
|
+
|
|
546
|
+
// If no explicit headRemote, check the branch's tracking info
|
|
547
|
+
if (!sourceRemote) {
|
|
548
|
+
const status = await getStatus(directory).catch(() => null);
|
|
549
|
+
if (status?.tracking) {
|
|
550
|
+
// tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main"
|
|
551
|
+
const trackingRemote = status.tracking.split('/')[0];
|
|
552
|
+
if (trackingRemote) {
|
|
553
|
+
sourceRemote = trackingRemote;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Fallback: if targeting non-origin and no tracking info, try 'origin'
|
|
559
|
+
if (!sourceRemote && remote !== 'origin') {
|
|
560
|
+
sourceRemote = 'origin';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const remoteNames = new Set([remote]);
|
|
564
|
+
const remotes = await getRemotes(directory).catch(() => []);
|
|
565
|
+
for (const item of remotes) {
|
|
566
|
+
if (item?.name) {
|
|
567
|
+
remoteNames.add(item.name);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (sourceRemote) {
|
|
571
|
+
remoteNames.add(sourceRemote);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const base = normalizeBranchRef(requestedBase, remoteNames);
|
|
575
|
+
if (!base) {
|
|
576
|
+
return res.status(400).json({ error: 'Invalid base branch name' });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// For fork workflows: we need to determine the correct head reference
|
|
580
|
+
let headRef = head;
|
|
581
|
+
let headRepo = null;
|
|
582
|
+
|
|
583
|
+
if (sourceRemote) {
|
|
584
|
+
// The branch is on a different remote than the target - this is a cross-repo PR
|
|
585
|
+
const resolved = await resolveGitHubRepoFromDirectory(directory, sourceRemote);
|
|
586
|
+
headRepo = resolved.repo;
|
|
587
|
+
if (!headRepo) {
|
|
588
|
+
return res.status(400).json({
|
|
589
|
+
error: `Cannot resolve GitHub repo for remote "${sourceRemote}". Check that the remote URL is a valid GitHub repository.`,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
// Always use owner:branch format for cross-repo PRs
|
|
593
|
+
// GitHub API requires this when head is from a different repo/fork
|
|
594
|
+
if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) {
|
|
595
|
+
headRef = `${headRepo.owner}:${head}`;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// For cross-repo PRs, verify the branch exists on the head repo first
|
|
600
|
+
if (headRef.includes(':')) {
|
|
601
|
+
const [headOwner] = headRef.split(':');
|
|
602
|
+
const headRepoName = headRepo?.repo || repo.repo;
|
|
603
|
+
|
|
604
|
+
if (headRepoName) {
|
|
605
|
+
try {
|
|
606
|
+
await octokit.rest.repos.getBranch({
|
|
607
|
+
owner: headOwner,
|
|
608
|
+
repo: headRepoName,
|
|
609
|
+
branch: head,
|
|
610
|
+
});
|
|
611
|
+
} catch (branchError) {
|
|
612
|
+
if (branchError?.status === 404) {
|
|
613
|
+
return res.status(400).json({
|
|
614
|
+
error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
// For other errors, continue - let the PR create attempt handle it
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const created = await octokit.rest.pulls.create({
|
|
623
|
+
owner: repo.owner,
|
|
624
|
+
repo: repo.repo,
|
|
625
|
+
title,
|
|
626
|
+
head: headRef,
|
|
627
|
+
base,
|
|
628
|
+
...(typeof body === 'string' ? { body } : {}),
|
|
629
|
+
...(typeof draft === 'boolean' ? { draft } : {}),
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const pr = created?.data;
|
|
633
|
+
if (!pr) {
|
|
634
|
+
return res.status(500).json({ error: 'Failed to create PR' });
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Invalidate PR status cache so subsequent prStatus calls fetch fresh data
|
|
638
|
+
const headBranch = head.includes(':') ? head.split(':')[1] || head : head;
|
|
639
|
+
const createCacheKey = `${directory}::${headBranch}::${remote}`;
|
|
640
|
+
prStatusCache.delete(createCacheKey);
|
|
641
|
+
|
|
642
|
+
return res.json({
|
|
643
|
+
number: pr.number,
|
|
644
|
+
title: pr.title,
|
|
645
|
+
body: pr.body || '',
|
|
646
|
+
url: pr.html_url,
|
|
647
|
+
state: pr.state === 'closed' ? 'closed' : 'open',
|
|
648
|
+
draft: Boolean(pr.draft),
|
|
649
|
+
base: pr.base?.ref,
|
|
650
|
+
head: pr.head?.ref,
|
|
651
|
+
headSha: pr.head?.sha,
|
|
652
|
+
mergeable: pr.mergeable,
|
|
653
|
+
mergeableState: pr.mergeable_state,
|
|
654
|
+
});
|
|
655
|
+
} catch (error) {
|
|
656
|
+
console.error('Failed to create GitHub PR:', error);
|
|
657
|
+
|
|
658
|
+
// Check for head validation error (common with fork PRs)
|
|
659
|
+
const errorMessage = error.message || '';
|
|
660
|
+
const isHeadValidationError =
|
|
661
|
+
errorMessage.includes('Validation Failed') &&
|
|
662
|
+
errorMessage.includes('"field":"head"') &&
|
|
663
|
+
errorMessage.includes('"code":"invalid"');
|
|
664
|
+
|
|
665
|
+
if (isHeadValidationError) {
|
|
666
|
+
return res.status(400).json({
|
|
667
|
+
error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.'
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
app.post('/api/github/pr/update', async (req, res) => {
|
|
676
|
+
try {
|
|
677
|
+
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|
678
|
+
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
679
|
+
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
|
680
|
+
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
|
|
681
|
+
if (!directory || !number || !title) {
|
|
682
|
+
return res.status(400).json({ error: 'directory, number, title are required' });
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
686
|
+
const octokit = getOctokitOrNull();
|
|
687
|
+
if (!octokit) {
|
|
688
|
+
return res.status(401).json({ error: 'GitHub not connected' });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
692
|
+
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
693
|
+
if (!repo) {
|
|
694
|
+
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
let updated;
|
|
698
|
+
try {
|
|
699
|
+
updated = await octokit.rest.pulls.update({
|
|
700
|
+
owner: repo.owner,
|
|
701
|
+
repo: repo.repo,
|
|
702
|
+
pull_number: number,
|
|
703
|
+
title,
|
|
704
|
+
...(typeof body === 'string' ? { body } : {}),
|
|
705
|
+
});
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (error?.status === 401) {
|
|
708
|
+
return res.status(401).json({ error: 'GitHub not connected' });
|
|
709
|
+
}
|
|
710
|
+
if (error?.status === 403) {
|
|
711
|
+
return res.status(403).json({ error: 'Not authorized to edit this PR' });
|
|
712
|
+
}
|
|
713
|
+
if (error?.status === 404) {
|
|
714
|
+
return res.status(404).json({ error: 'PR not found in this repository' });
|
|
715
|
+
}
|
|
716
|
+
if (error?.status === 422) {
|
|
717
|
+
const apiMessage = error?.response?.data?.message;
|
|
718
|
+
const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0
|
|
719
|
+
? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code)
|
|
720
|
+
: null;
|
|
721
|
+
const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload';
|
|
722
|
+
return res.status(422).json({ error: message });
|
|
723
|
+
}
|
|
724
|
+
throw error;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const pr = updated?.data;
|
|
728
|
+
if (!pr) {
|
|
729
|
+
return res.status(500).json({ error: 'Failed to update PR' });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return res.json({
|
|
733
|
+
number: pr.number,
|
|
734
|
+
title: pr.title,
|
|
735
|
+
body: pr.body || '',
|
|
736
|
+
url: pr.html_url,
|
|
737
|
+
state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'),
|
|
738
|
+
draft: Boolean(pr.draft),
|
|
739
|
+
base: pr.base?.ref,
|
|
740
|
+
head: pr.head?.ref,
|
|
741
|
+
headSha: pr.head?.sha,
|
|
742
|
+
mergeable: pr.mergeable,
|
|
743
|
+
mergeableState: pr.mergeable_state,
|
|
744
|
+
});
|
|
745
|
+
} catch (error) {
|
|
746
|
+
console.error('Failed to update GitHub PR:', error);
|
|
747
|
+
return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' });
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
app.post('/api/github/pr/merge', async (req, res) => {
|
|
752
|
+
try {
|
|
753
|
+
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|
754
|
+
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
755
|
+
const method = typeof req.body?.method === 'string' ? req.body.method : 'merge';
|
|
756
|
+
if (!directory || !number) {
|
|
757
|
+
return res.status(400).json({ error: 'directory and number are required' });
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
761
|
+
const octokit = getOctokitOrNull();
|
|
762
|
+
if (!octokit) {
|
|
763
|
+
return res.status(401).json({ error: 'GitHub not connected' });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
767
|
+
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
768
|
+
if (!repo) {
|
|
769
|
+
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
const result = await octokit.rest.pulls.merge({
|
|
774
|
+
owner: repo.owner,
|
|
775
|
+
repo: repo.repo,
|
|
776
|
+
pull_number: number,
|
|
777
|
+
merge_method: method,
|
|
778
|
+
});
|
|
779
|
+
return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
|
|
780
|
+
} catch (error) {
|
|
781
|
+
if (error?.status === 403) {
|
|
782
|
+
return res.status(403).json({ error: 'Not authorized to merge this PR' });
|
|
783
|
+
}
|
|
784
|
+
if (error?.status === 405 || error?.status === 409) {
|
|
785
|
+
return res.json({ merged: false, message: error?.message || 'PR not mergeable' });
|
|
786
|
+
}
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
} catch (error) {
|
|
790
|
+
console.error('Failed to merge GitHub PR:', error);
|
|
791
|
+
return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' });
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
app.post('/api/github/pr/ready', async (req, res) => {
|
|
796
|
+
try {
|
|
797
|
+
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|
798
|
+
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|
799
|
+
if (!directory || !number) {
|
|
800
|
+
return res.status(400).json({ error: 'directory and number are required' });
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
804
|
+
const octokit = getOctokitOrNull();
|
|
805
|
+
if (!octokit) {
|
|
806
|
+
return res.status(401).json({ error: 'GitHub not connected' });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
810
|
+
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
811
|
+
if (!repo) {
|
|
812
|
+
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
|
|
816
|
+
const nodeId = pr?.data?.node_id;
|
|
817
|
+
if (!nodeId) {
|
|
818
|
+
return res.status(500).json({ error: 'Failed to resolve PR node id' });
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (pr?.data?.draft === false) {
|
|
822
|
+
return res.json({ ready: true });
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
await octokit.graphql(
|
|
827
|
+
`mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`,
|
|
828
|
+
{ pullRequestId: nodeId }
|
|
829
|
+
);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
if (error?.status === 403) {
|
|
832
|
+
return res.status(403).json({ error: 'Not authorized to mark PR ready' });
|
|
833
|
+
}
|
|
834
|
+
throw error;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return res.json({ ready: true });
|
|
838
|
+
} catch (error) {
|
|
839
|
+
console.error('Failed to mark PR ready:', error);
|
|
840
|
+
return res.status(500).json({ error: error.message || 'Failed to mark PR ready' });
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
// ================= GitHub Repo APIs =================
|
|
845
|
+
|
|
846
|
+
app.get('/api/github/repo/upstream', async (req, res) => {
|
|
847
|
+
try {
|
|
848
|
+
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|
849
|
+
if (!directory) {
|
|
850
|
+
return res.status(400).json({ error: 'directory is required' });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
854
|
+
const octokit = getOctokitOrNull();
|
|
855
|
+
if (!octokit) {
|
|
856
|
+
return res.json({ connected: false, isFork: false, upstream: null });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
|
|
860
|
+
const network = await resolveRepoNetwork(octokit, directory);
|
|
861
|
+
|
|
862
|
+
if (!network || network.length <= 1) {
|
|
863
|
+
return res.json({ connected: true, isFork: false, upstream: null });
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const upstream = network.find((r) => r.source === 'upstream') || null;
|
|
867
|
+
let defaultBranch = 'main';
|
|
868
|
+
let defaultBranchSha = null;
|
|
869
|
+
if (upstream) {
|
|
870
|
+
try {
|
|
871
|
+
const metadata = await octokit.rest.repos.get({ owner: upstream.owner, repo: upstream.repo });
|
|
872
|
+
defaultBranch = metadata?.data?.default_branch || 'main';
|
|
873
|
+
const ref = await octokit.rest.git.getRef({ owner: upstream.owner, repo: upstream.repo, ref: `heads/${defaultBranch}` });
|
|
874
|
+
defaultBranchSha = ref?.data?.object?.sha || null;
|
|
875
|
+
} catch {
|
|
876
|
+
// Fall back if metadata/ref fetch fails
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Check if a configured git remote points to the upstream repo
|
|
881
|
+
let upstreamRemoteName = null;
|
|
882
|
+
if (upstream) {
|
|
883
|
+
try {
|
|
884
|
+
const { getRemotes } = await import('../git/index.js');
|
|
885
|
+
const remotes = await getRemotes(directory);
|
|
886
|
+
for (const r of remotes) {
|
|
887
|
+
if (r?.name) {
|
|
888
|
+
const resolved = await resolveGitHubRepoFromDirectory(directory, r.name).catch(() => ({ repo: null }));
|
|
889
|
+
if (resolved.repo && resolved.repo.owner === upstream.owner && resolved.repo.repo === upstream.repo) {
|
|
890
|
+
upstreamRemoteName = r.name;
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
} catch {
|
|
896
|
+
// Ignore errors finding remote name
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return res.json({
|
|
901
|
+
connected: true,
|
|
902
|
+
isFork: Boolean(upstream),
|
|
903
|
+
upstream: upstream ? { owner: upstream.owner, repo: upstream.repo, url: upstream.url, defaultBranch, defaultBranchSha, remoteName: upstreamRemoteName } : null,
|
|
904
|
+
});
|
|
905
|
+
} catch (error) {
|
|
906
|
+
console.error('Failed to detect upstream repo:', error);
|
|
907
|
+
return res.status(500).json({ error: error.message || 'Failed to detect upstream repo' });
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
app.get('/api/github/repo/branches', async (req, res) => {
|
|
912
|
+
try {
|
|
913
|
+
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
|
|
914
|
+
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
|
|
915
|
+
if (!owner || !repo) {
|
|
916
|
+
return res.status(400).json({ error: 'owner and repo are required' });
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
920
|
+
const octokit = getOctokitOrNull();
|
|
921
|
+
if (!octokit) {
|
|
922
|
+
return res.json({ branches: [] });
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const branches = [];
|
|
926
|
+
let page = 1;
|
|
927
|
+
while (true) {
|
|
928
|
+
const response = await octokit.rest.repos.listBranches({ owner, repo, per_page: 100, page });
|
|
929
|
+
if (!response.data || response.data.length === 0) break;
|
|
930
|
+
for (const branch of response.data) {
|
|
931
|
+
branches.push(branch.name);
|
|
932
|
+
}
|
|
933
|
+
if (response.data.length < 100) break;
|
|
934
|
+
page++;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return res.json({ branches });
|
|
938
|
+
} catch (error) {
|
|
939
|
+
console.error('Failed to fetch repo branches:', error);
|
|
940
|
+
return res.status(500).json({ error: error.message || 'Failed to fetch repo branches' });
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// ================= GitHub Issue APIs =================
|
|
945
|
+
|
|
946
|
+
app.get('/api/github/issues/list', async (req, res) => {
|
|
947
|
+
try {
|
|
948
|
+
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|
949
|
+
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
|
950
|
+
if (!directory) {
|
|
951
|
+
return res.status(400).json({ error: 'directory is required' });
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
955
|
+
const octokit = getOctokitOrNull();
|
|
956
|
+
if (!octokit) {
|
|
957
|
+
return res.json({ connected: false });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
961
|
+
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
|
|
962
|
+
|
|
963
|
+
const repoNetwork = await resolveRepoNetwork(octokit, directory);
|
|
964
|
+
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
965
|
+
if (!repo) {
|
|
966
|
+
return res.json({ connected: true, repo: null, issues: [] });
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
|
|
970
|
+
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
|
|
971
|
+
|
|
972
|
+
const queryRepo = async (repoRef) => {
|
|
973
|
+
try {
|
|
974
|
+
const list = await octokit.rest.issues.listForRepo({
|
|
975
|
+
owner: repoRef.owner,
|
|
976
|
+
repo: repoRef.repo,
|
|
977
|
+
state: 'open',
|
|
978
|
+
per_page: 50,
|
|
979
|
+
page: effectivePage,
|
|
980
|
+
});
|
|
981
|
+
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
|
|
982
|
+
const hasMore = /rel="next"/.test(link);
|
|
983
|
+
const issues = (Array.isArray(list?.data) ? list.data : [])
|
|
984
|
+
.filter((item) => !item?.pull_request)
|
|
985
|
+
.map((item) => ({
|
|
986
|
+
number: item.number,
|
|
987
|
+
title: item.title,
|
|
988
|
+
url: item.html_url,
|
|
989
|
+
state: item.state === 'closed' ? 'closed' : 'open',
|
|
990
|
+
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
|
|
991
|
+
labels: Array.isArray(item.labels)
|
|
992
|
+
? item.labels
|
|
993
|
+
.map((label) => {
|
|
994
|
+
if (typeof label === 'string') return null;
|
|
995
|
+
const name = typeof label?.name === 'string' ? label.name : '';
|
|
996
|
+
if (!name) return null;
|
|
997
|
+
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
|
998
|
+
})
|
|
999
|
+
.filter(Boolean)
|
|
1000
|
+
: [],
|
|
1001
|
+
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
|
|
1002
|
+
}));
|
|
1003
|
+
return { issues, hasMore };
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
console.warn(`Failed to list issues for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
|
|
1006
|
+
return { issues: [], hasMore: false };
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const results = await Promise.all(reposToQuery.map(queryRepo));
|
|
1011
|
+
const allIssues = results.flatMap((r) => r.issues);
|
|
1012
|
+
const anyHasMore = results.some((r) => r.hasMore);
|
|
1013
|
+
|
|
1014
|
+
return res.json({ connected: true, repo, issues: allIssues, page: effectivePage, hasMore: anyHasMore });
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
console.error('Failed to list GitHub issues:', error);
|
|
1017
|
+
return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
app.get('/api/github/issues/get', async (req, res) => {
|
|
1022
|
+
try {
|
|
1023
|
+
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|
1024
|
+
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
|
|
1025
|
+
if (!directory || !number) {
|
|
1026
|
+
return res.status(400).json({ error: 'directory and number are required' });
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
1030
|
+
const octokit = getOctokitOrNull();
|
|
1031
|
+
if (!octokit) {
|
|
1032
|
+
return res.json({ connected: false });
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const requestedRepo = getRequestedRepo(req);
|
|
1036
|
+
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
|
1037
|
+
if (!repo) {
|
|
1038
|
+
return res.json({ connected: true, repo: null, issue: null });
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number });
|
|
1042
|
+
const issue = result?.data;
|
|
1043
|
+
if (!issue || issue.pull_request) {
|
|
1044
|
+
return res.status(400).json({ error: 'Not a GitHub issue' });
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
return res.json({
|
|
1048
|
+
connected: true,
|
|
1049
|
+
repo,
|
|
1050
|
+
issue: {
|
|
1051
|
+
number: issue.number,
|
|
1052
|
+
title: issue.title,
|
|
1053
|
+
url: issue.html_url,
|
|
1054
|
+
state: issue.state === 'closed' ? 'closed' : 'open',
|
|
1055
|
+
body: issue.body || '',
|
|
1056
|
+
createdAt: issue.created_at,
|
|
1057
|
+
updatedAt: issue.updated_at,
|
|
1058
|
+
author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
|
|
1059
|
+
assignees: Array.isArray(issue.assignees)
|
|
1060
|
+
? issue.assignees
|
|
1061
|
+
.map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
|
|
1062
|
+
.filter(Boolean)
|
|
1063
|
+
: [],
|
|
1064
|
+
labels: Array.isArray(issue.labels)
|
|
1065
|
+
? issue.labels
|
|
1066
|
+
.map((label) => {
|
|
1067
|
+
if (typeof label === 'string') return null;
|
|
1068
|
+
const name = typeof label?.name === 'string' ? label.name : '';
|
|
1069
|
+
if (!name) return null;
|
|
1070
|
+
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
|
1071
|
+
})
|
|
1072
|
+
.filter(Boolean)
|
|
1073
|
+
: [],
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
console.error('Failed to fetch GitHub issue:', error);
|
|
1078
|
+
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' });
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
app.get('/api/github/issues/comments', async (req, res) => {
|
|
1083
|
+
try {
|
|
1084
|
+
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|
1085
|
+
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
|
|
1086
|
+
if (!directory || !number) {
|
|
1087
|
+
return res.status(400).json({ error: 'directory and number are required' });
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
1091
|
+
const octokit = getOctokitOrNull();
|
|
1092
|
+
if (!octokit) {
|
|
1093
|
+
return res.json({ connected: false });
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const requestedRepo = getRequestedRepo(req);
|
|
1097
|
+
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
|
1098
|
+
if (!repo) {
|
|
1099
|
+
return res.json({ connected: true, repo: null, comments: [] });
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const result = await octokit.rest.issues.listComments({
|
|
1103
|
+
owner: repo.owner,
|
|
1104
|
+
repo: repo.repo,
|
|
1105
|
+
issue_number: number,
|
|
1106
|
+
per_page: 100,
|
|
1107
|
+
});
|
|
1108
|
+
const comments = (Array.isArray(result?.data) ? result.data : [])
|
|
1109
|
+
.map((comment) => ({
|
|
1110
|
+
id: comment.id,
|
|
1111
|
+
url: comment.html_url,
|
|
1112
|
+
body: comment.body || '',
|
|
1113
|
+
createdAt: comment.created_at,
|
|
1114
|
+
updatedAt: comment.updated_at,
|
|
1115
|
+
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
|
1116
|
+
}));
|
|
1117
|
+
|
|
1118
|
+
return res.json({ connected: true, repo, comments });
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
console.error('Failed to fetch GitHub issue comments:', error);
|
|
1121
|
+
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' });
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// ================= GitHub Pull Request Context APIs =================
|
|
1126
|
+
|
|
1127
|
+
app.get('/api/github/pulls/list', async (req, res) => {
|
|
1128
|
+
try {
|
|
1129
|
+
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|
1130
|
+
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
|
1131
|
+
if (!directory) {
|
|
1132
|
+
return res.status(400).json({ error: 'directory is required' });
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
1136
|
+
const octokit = getOctokitOrNull();
|
|
1137
|
+
if (!octokit) {
|
|
1138
|
+
return res.json({ connected: false });
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
|
1142
|
+
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
|
|
1143
|
+
|
|
1144
|
+
const repoNetwork = await resolveRepoNetwork(octokit, directory);
|
|
1145
|
+
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|
1146
|
+
if (!repo) {
|
|
1147
|
+
return res.json({ connected: true, repo: null, prs: [] });
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
|
|
1151
|
+
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
|
|
1152
|
+
|
|
1153
|
+
const queryRepo = async (repoRef) => {
|
|
1154
|
+
try {
|
|
1155
|
+
const list = await octokit.rest.pulls.list({
|
|
1156
|
+
owner: repoRef.owner,
|
|
1157
|
+
repo: repoRef.repo,
|
|
1158
|
+
state: 'open',
|
|
1159
|
+
per_page: 50,
|
|
1160
|
+
page: effectivePage,
|
|
1161
|
+
});
|
|
1162
|
+
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
|
|
1163
|
+
const hasMore = /rel="next"/.test(link);
|
|
1164
|
+
const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => {
|
|
1165
|
+
const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
|
|
1166
|
+
const headRepo = pr.head?.repo
|
|
1167
|
+
? {
|
|
1168
|
+
owner: pr.head.repo.owner?.login,
|
|
1169
|
+
repo: pr.head.repo.name,
|
|
1170
|
+
url: pr.head.repo.html_url,
|
|
1171
|
+
cloneUrl: pr.head.repo.clone_url,
|
|
1172
|
+
sshUrl: pr.head.repo.ssh_url,
|
|
1173
|
+
}
|
|
1174
|
+
: null;
|
|
1175
|
+
return {
|
|
1176
|
+
number: pr.number,
|
|
1177
|
+
title: pr.title,
|
|
1178
|
+
url: pr.html_url,
|
|
1179
|
+
state: mergedState,
|
|
1180
|
+
draft: Boolean(pr.draft),
|
|
1181
|
+
base: pr.base?.ref,
|
|
1182
|
+
head: pr.head?.ref,
|
|
1183
|
+
headSha: pr.head?.sha,
|
|
1184
|
+
mergeable: pr.mergeable,
|
|
1185
|
+
mergeableState: pr.mergeable_state,
|
|
1186
|
+
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
|
|
1187
|
+
headLabel: pr.head?.label,
|
|
1188
|
+
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
|
|
1189
|
+
? headRepo
|
|
1190
|
+
: null,
|
|
1191
|
+
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
|
|
1192
|
+
};
|
|
1193
|
+
});
|
|
1194
|
+
return { prs, hasMore };
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
console.warn(`Failed to list PRs for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
|
|
1197
|
+
return { prs: [], hasMore: false };
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
const results = await Promise.all(reposToQuery.map(queryRepo));
|
|
1202
|
+
const allPrs = results.flatMap((r) => r.prs);
|
|
1203
|
+
const anyHasMore = results.some((r) => r.hasMore);
|
|
1204
|
+
|
|
1205
|
+
return res.json({ connected: true, repo, prs: allPrs, page: effectivePage, hasMore: anyHasMore });
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
if (error?.status === 401) {
|
|
1208
|
+
const { clearGitHubAuth } = await getGitHubLibraries();
|
|
1209
|
+
clearGitHubAuth();
|
|
1210
|
+
return res.json({ connected: false });
|
|
1211
|
+
}
|
|
1212
|
+
console.error('Failed to list GitHub pull requests:', error);
|
|
1213
|
+
return res.status(500).json({ error: error.message || 'Failed to list GitHub pull requests' });
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
app.get('/api/github/pulls/context', async (req, res) => {
|
|
1218
|
+
try {
|
|
1219
|
+
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|
1220
|
+
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
|
|
1221
|
+
const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true';
|
|
1222
|
+
const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true';
|
|
1223
|
+
if (!directory || !number) {
|
|
1224
|
+
return res.status(400).json({ error: 'directory and number are required' });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const { getOctokitOrNull } = await getGitHubLibraries();
|
|
1228
|
+
const octokit = getOctokitOrNull();
|
|
1229
|
+
if (!octokit) {
|
|
1230
|
+
return res.json({ connected: false });
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const requestedRepo = getRequestedRepo(req);
|
|
1234
|
+
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
|
1235
|
+
if (!repo) {
|
|
1236
|
+
return res.json({ connected: true, repo: null, pr: null });
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
|
|
1240
|
+
const prData = prResp?.data;
|
|
1241
|
+
if (!prData) {
|
|
1242
|
+
return res.status(404).json({ error: 'PR not found' });
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const headRepo = prData.head?.repo
|
|
1246
|
+
? {
|
|
1247
|
+
owner: prData.head.repo.owner?.login,
|
|
1248
|
+
repo: prData.head.repo.name,
|
|
1249
|
+
url: prData.head.repo.html_url,
|
|
1250
|
+
cloneUrl: prData.head.repo.clone_url,
|
|
1251
|
+
sshUrl: prData.head.repo.ssh_url,
|
|
1252
|
+
}
|
|
1253
|
+
: null;
|
|
1254
|
+
|
|
1255
|
+
const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
|
|
1256
|
+
const pr = {
|
|
1257
|
+
number: prData.number,
|
|
1258
|
+
title: prData.title,
|
|
1259
|
+
url: prData.html_url,
|
|
1260
|
+
state: mergedState,
|
|
1261
|
+
draft: Boolean(prData.draft),
|
|
1262
|
+
base: prData.base?.ref,
|
|
1263
|
+
head: prData.head?.ref,
|
|
1264
|
+
headSha: prData.head?.sha,
|
|
1265
|
+
mergeable: prData.mergeable,
|
|
1266
|
+
mergeableState: prData.mergeable_state,
|
|
1267
|
+
author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null,
|
|
1268
|
+
headLabel: prData.head?.label,
|
|
1269
|
+
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null,
|
|
1270
|
+
body: prData.body || '',
|
|
1271
|
+
createdAt: prData.created_at,
|
|
1272
|
+
updatedAt: prData.updated_at,
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const issueCommentsResp = await octokit.rest.issues.listComments({
|
|
1276
|
+
owner: repo.owner,
|
|
1277
|
+
repo: repo.repo,
|
|
1278
|
+
issue_number: number,
|
|
1279
|
+
per_page: 100,
|
|
1280
|
+
});
|
|
1281
|
+
const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({
|
|
1282
|
+
id: comment.id,
|
|
1283
|
+
url: comment.html_url,
|
|
1284
|
+
body: comment.body || '',
|
|
1285
|
+
createdAt: comment.created_at,
|
|
1286
|
+
updatedAt: comment.updated_at,
|
|
1287
|
+
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
|
1288
|
+
}));
|
|
1289
|
+
|
|
1290
|
+
const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({
|
|
1291
|
+
owner: repo.owner,
|
|
1292
|
+
repo: repo.repo,
|
|
1293
|
+
pull_number: number,
|
|
1294
|
+
per_page: 100,
|
|
1295
|
+
});
|
|
1296
|
+
const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({
|
|
1297
|
+
id: comment.id,
|
|
1298
|
+
url: comment.html_url,
|
|
1299
|
+
body: comment.body || '',
|
|
1300
|
+
createdAt: comment.created_at,
|
|
1301
|
+
updatedAt: comment.updated_at,
|
|
1302
|
+
path: comment.path,
|
|
1303
|
+
line: typeof comment.line === 'number' ? comment.line : null,
|
|
1304
|
+
position: typeof comment.position === 'number' ? comment.position : null,
|
|
1305
|
+
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
|
1306
|
+
}));
|
|
1307
|
+
|
|
1308
|
+
const filesResp = await octokit.rest.pulls.listFiles({
|
|
1309
|
+
owner: repo.owner,
|
|
1310
|
+
repo: repo.repo,
|
|
1311
|
+
pull_number: number,
|
|
1312
|
+
per_page: 100,
|
|
1313
|
+
});
|
|
1314
|
+
const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({
|
|
1315
|
+
filename: f.filename,
|
|
1316
|
+
status: f.status,
|
|
1317
|
+
additions: f.additions,
|
|
1318
|
+
deletions: f.deletions,
|
|
1319
|
+
changes: f.changes,
|
|
1320
|
+
patch: f.patch,
|
|
1321
|
+
}));
|
|
1322
|
+
|
|
1323
|
+
// checks summary (same logic as status endpoint)
|
|
1324
|
+
let checks = null;
|
|
1325
|
+
let checkRunsOut = undefined;
|
|
1326
|
+
const sha = prData.head?.sha;
|
|
1327
|
+
if (sha) {
|
|
1328
|
+
try {
|
|
1329
|
+
const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 });
|
|
1330
|
+
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
|
|
1331
|
+
if (checkRuns.length > 0) {
|
|
1332
|
+
const parsedJobs = new Map();
|
|
1333
|
+
const parsedAnnotations = new Map();
|
|
1334
|
+
if (includeCheckDetails) {
|
|
1335
|
+
// Prefetch actions jobs per runId.
|
|
1336
|
+
const runIds = new Set();
|
|
1337
|
+
const jobIds = new Map();
|
|
1338
|
+
for (const run of checkRuns) {
|
|
1339
|
+
const details = typeof run.details_url === 'string' ? run.details_url : '';
|
|
1340
|
+
const match = details.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
|
|
1341
|
+
if (match) {
|
|
1342
|
+
const runId = Number(match[1]);
|
|
1343
|
+
const jobId = match[2] ? Number(match[2]) : null;
|
|
1344
|
+
if (Number.isFinite(runId) && runId > 0) {
|
|
1345
|
+
runIds.add(runId);
|
|
1346
|
+
if (jobId && Number.isFinite(jobId) && jobId > 0) {
|
|
1347
|
+
jobIds.set(details, { runId, jobId });
|
|
1348
|
+
} else {
|
|
1349
|
+
jobIds.set(details, { runId, jobId: null });
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
for (const runId of runIds) {
|
|
1356
|
+
try {
|
|
1357
|
+
const jobsResp = await octokit.rest.actions.listJobsForWorkflowRun({
|
|
1358
|
+
owner: repo.owner,
|
|
1359
|
+
repo: repo.repo,
|
|
1360
|
+
run_id: runId,
|
|
1361
|
+
per_page: 100,
|
|
1362
|
+
});
|
|
1363
|
+
const jobs = Array.isArray(jobsResp?.data?.jobs) ? jobsResp.data.jobs : [];
|
|
1364
|
+
parsedJobs.set(runId, jobs);
|
|
1365
|
+
} catch {
|
|
1366
|
+
parsedJobs.set(runId, []);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
for (const run of checkRuns) {
|
|
1371
|
+
const runConclusion = typeof run?.conclusion === 'string' ? run.conclusion.toLowerCase() : '';
|
|
1372
|
+
const shouldLoadAnnotations = Boolean(
|
|
1373
|
+
run?.id
|
|
1374
|
+
&& runConclusion
|
|
1375
|
+
&& !['success', 'neutral', 'skipped'].includes(runConclusion)
|
|
1376
|
+
);
|
|
1377
|
+
if (!shouldLoadAnnotations) {
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const checkRunId = Number(run.id);
|
|
1382
|
+
if (!Number.isFinite(checkRunId) || checkRunId <= 0) {
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const annotations = [];
|
|
1387
|
+
for (let page = 1; page <= 3; page += 1) {
|
|
1388
|
+
try {
|
|
1389
|
+
const annotationsResp = await octokit.rest.checks.listAnnotations({
|
|
1390
|
+
owner: repo.owner,
|
|
1391
|
+
repo: repo.repo,
|
|
1392
|
+
check_run_id: checkRunId,
|
|
1393
|
+
per_page: 50,
|
|
1394
|
+
page,
|
|
1395
|
+
});
|
|
1396
|
+
const chunk = Array.isArray(annotationsResp?.data) ? annotationsResp.data : [];
|
|
1397
|
+
annotations.push(...chunk);
|
|
1398
|
+
if (chunk.length < 50) {
|
|
1399
|
+
break;
|
|
1400
|
+
}
|
|
1401
|
+
} catch {
|
|
1402
|
+
break;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
if (annotations.length > 0) {
|
|
1407
|
+
parsedAnnotations.set(checkRunId, annotations);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
checkRunsOut = checkRuns.map((run) => {
|
|
1413
|
+
const detailsUrl = typeof run.details_url === 'string' ? run.details_url : undefined;
|
|
1414
|
+
let job = undefined;
|
|
1415
|
+
if (includeCheckDetails && detailsUrl) {
|
|
1416
|
+
const match = detailsUrl.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
|
|
1417
|
+
const runId = match ? Number(match[1]) : null;
|
|
1418
|
+
const jobId = match && match[2] ? Number(match[2]) : null;
|
|
1419
|
+
if (runId && Number.isFinite(runId)) {
|
|
1420
|
+
const jobs = parsedJobs.get(runId) || [];
|
|
1421
|
+
const matched = jobId
|
|
1422
|
+
? jobs.find((j) => j.id === jobId)
|
|
1423
|
+
: null;
|
|
1424
|
+
const picked = matched || jobs.find((j) => j.name === run.name) || null;
|
|
1425
|
+
if (picked) {
|
|
1426
|
+
job = {
|
|
1427
|
+
runId,
|
|
1428
|
+
jobId: picked.id,
|
|
1429
|
+
url: picked.html_url,
|
|
1430
|
+
name: picked.name,
|
|
1431
|
+
conclusion: picked.conclusion,
|
|
1432
|
+
steps: Array.isArray(picked.steps)
|
|
1433
|
+
? picked.steps.map((s) => ({
|
|
1434
|
+
name: s.name,
|
|
1435
|
+
status: s.status,
|
|
1436
|
+
conclusion: s.conclusion,
|
|
1437
|
+
number: s.number,
|
|
1438
|
+
startedAt: s.started_at || undefined,
|
|
1439
|
+
completedAt: s.completed_at || undefined,
|
|
1440
|
+
}))
|
|
1441
|
+
: undefined,
|
|
1442
|
+
};
|
|
1443
|
+
} else {
|
|
1444
|
+
job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl };
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return {
|
|
1450
|
+
id: run.id,
|
|
1451
|
+
name: run.name,
|
|
1452
|
+
app: run.app
|
|
1453
|
+
? {
|
|
1454
|
+
name: run.app.name || undefined,
|
|
1455
|
+
slug: run.app.slug || undefined,
|
|
1456
|
+
}
|
|
1457
|
+
: undefined,
|
|
1458
|
+
status: run.status,
|
|
1459
|
+
conclusion: run.conclusion,
|
|
1460
|
+
detailsUrl,
|
|
1461
|
+
output: run.output
|
|
1462
|
+
? {
|
|
1463
|
+
title: run.output.title || undefined,
|
|
1464
|
+
summary: run.output.summary || undefined,
|
|
1465
|
+
text: run.output.text || undefined,
|
|
1466
|
+
}
|
|
1467
|
+
: undefined,
|
|
1468
|
+
...(job ? { job } : {}),
|
|
1469
|
+
...(run.id && parsedAnnotations.has(run.id)
|
|
1470
|
+
? {
|
|
1471
|
+
annotations: parsedAnnotations.get(run.id).map((a) => ({
|
|
1472
|
+
path: a.path || undefined,
|
|
1473
|
+
startLine: typeof a.start_line === 'number' ? a.start_line : undefined,
|
|
1474
|
+
endLine: typeof a.end_line === 'number' ? a.end_line : undefined,
|
|
1475
|
+
level: a.annotation_level || undefined,
|
|
1476
|
+
message: a.message || '',
|
|
1477
|
+
title: a.title || undefined,
|
|
1478
|
+
rawDetails: a.raw_details || undefined,
|
|
1479
|
+
})).filter((a) => a.message),
|
|
1480
|
+
}
|
|
1481
|
+
: {}),
|
|
1482
|
+
};
|
|
1483
|
+
});
|
|
1484
|
+
const counts = { success: 0, failure: 0, pending: 0 };
|
|
1485
|
+
for (const run of checkRuns) {
|
|
1486
|
+
const status = run?.status;
|
|
1487
|
+
const conclusion = run?.conclusion;
|
|
1488
|
+
if (status === 'queued' || status === 'in_progress') {
|
|
1489
|
+
counts.pending += 1;
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
if (!conclusion) {
|
|
1493
|
+
counts.pending += 1;
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
|
1497
|
+
counts.success += 1;
|
|
1498
|
+
} else {
|
|
1499
|
+
counts.failure += 1;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const total = counts.success + counts.failure + counts.pending;
|
|
1503
|
+
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|
1504
|
+
checks = { state, total, ...counts };
|
|
1505
|
+
}
|
|
1506
|
+
} catch {
|
|
1507
|
+
// ignore and fall back
|
|
1508
|
+
}
|
|
1509
|
+
if (!checks) {
|
|
1510
|
+
try {
|
|
1511
|
+
const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha });
|
|
1512
|
+
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
|
|
1513
|
+
const counts = { success: 0, failure: 0, pending: 0 };
|
|
1514
|
+
statuses.forEach((s) => {
|
|
1515
|
+
if (s.state === 'success') counts.success += 1;
|
|
1516
|
+
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
|
1517
|
+
else if (s.state === 'pending') counts.pending += 1;
|
|
1518
|
+
});
|
|
1519
|
+
const total = counts.success + counts.failure + counts.pending;
|
|
1520
|
+
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|
1521
|
+
checks = { state, total, ...counts };
|
|
1522
|
+
} catch {
|
|
1523
|
+
checks = null;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
let diff = undefined;
|
|
1529
|
+
if (includeDiff) {
|
|
1530
|
+
const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
|
|
1531
|
+
owner: repo.owner,
|
|
1532
|
+
repo: repo.repo,
|
|
1533
|
+
pull_number: number,
|
|
1534
|
+
headers: { accept: 'application/vnd.github.v3.diff' },
|
|
1535
|
+
});
|
|
1536
|
+
diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
return res.json({
|
|
1540
|
+
connected: true,
|
|
1541
|
+
repo,
|
|
1542
|
+
pr,
|
|
1543
|
+
issueComments,
|
|
1544
|
+
reviewComments,
|
|
1545
|
+
files,
|
|
1546
|
+
...(diff ? { diff } : {}),
|
|
1547
|
+
checks,
|
|
1548
|
+
...(Array.isArray(checkRunsOut) ? { checkRuns: checkRunsOut } : {}),
|
|
1549
|
+
});
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
if (error?.status === 401) {
|
|
1552
|
+
const { clearGitHubAuth } = await getGitHubLibraries();
|
|
1553
|
+
clearGitHubAuth();
|
|
1554
|
+
return res.json({ connected: false });
|
|
1555
|
+
}
|
|
1556
|
+
console.error('Failed to load GitHub PR context:', error);
|
|
1557
|
+
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' });
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
}
|