From b034d43cbe73efeaeedd9d700dd96517f375bcf4 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 10 Aug 2026 06:54:49 +0000 Subject: [PATCH] feat: implement branch linking functionality and enhance error handling --- backend/branches.go | 14 +- frontend/src/GitHubTaskSection.tsx | 239 ++++++++++++++++++++++++++++- frontend/src/github-api.ts | 14 ++ mcp/bun.lock | 4 +- mcp/package.json | 2 +- mcp/src/index.ts | 45 ++++++ plugin.json | 7 +- 7 files changed, 307 insertions(+), 18 deletions(-) diff --git a/backend/branches.go b/backend/branches.go index a45dd6b..949f561 100644 --- a/backend/branches.go +++ b/backend/branches.go @@ -148,10 +148,16 @@ func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Respons ghc := newGHClient(token) if err := ghc.branchExists(context.Background(), owner, repoName, b.BranchName); err != nil { var apiErr *ghAPIError - if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { - apiError(res, 404, "GITHUB_BRANCH_NOT_FOUND", - fmt.Sprintf("Branch %q not found in %s/%s", b.BranchName, owner, repoName)) - return + if errors.As(err, &apiErr) { + if apiErr.StatusCode == 404 { + apiError(res, 404, "GITHUB_BRANCH_NOT_FOUND", + fmt.Sprintf("Branch %q not found in %s/%s", b.BranchName, owner, repoName)) + return + } + if apiErr.StatusCode == 403 { + apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to read branches") + return + } } apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to verify branch: %s", err)) return diff --git a/frontend/src/GitHubTaskSection.tsx b/frontend/src/GitHubTaskSection.tsx index e5ab79b..3e716f0 100644 --- a/frontend/src/GitHubTaskSection.tsx +++ b/frontend/src/GitHubTaskSection.tsx @@ -10,6 +10,7 @@ import { GitMerge, GitPullRequest, GitPullRequestClosed, + Link2, Loader2, Plus, Terminal, @@ -22,6 +23,7 @@ import { ErrorCode, getPluginErrorCode, type LinkedRepository, + linkBranchToTask, linkedReposKey, listLinkedRepositories, listTaskBranches, @@ -754,6 +756,207 @@ function CreateBranchForm({ ); } +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function parseGitHubBranchUrl( + raw: string, +): { fullName: string; branchName: string } | null { + try { + const url = new URL(raw.trim()); + if (url.hostname !== "github.com") return null; + const parts = url.pathname.replace(/^\//, "").split("/"); + if (parts.length < 4 || parts[2] !== "tree") return null; + const branchName = parts.slice(3).join("/"); + if (!branchName) return null; + return { fullName: `${parts[0]}/${parts[1]}`, branchName }; + } catch { + return null; + } +} + +// ── Link branch form ────────────────────────────────────────────────────────── + +function LinkBranchForm({ + api, + projectId, + taskId, + repos, + onDone, +}: { + api: PluginApiClient; + projectId: string; + taskId: string; + repos: LinkedRepository[]; + onDone: () => void; +}) { + const queryClient = useQueryClient(); + const [selectedRepoId, setSelectedRepoId] = useState( + repos.length === 1 ? repos[0].id : "", + ); + const [value, setValue] = useState(""); + const [error, setError] = useState(null); + + const parsed = parseGitHubBranchUrl(value); + const urlMatchedRepo = parsed + ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) + : null; + const effectiveRepoId = parsed ? (urlMatchedRepo?.id ?? "") : selectedRepoId; + const branchName = parsed ? parsed.branchName : value.trim(); + + const mutation = useMutation({ + mutationFn: () => linkBranchToTask(api, taskId, effectiveRepoId, branchName), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: taskBranchesKey(projectId, taskId), + }); + setValue(""); + setError(null); + onDone(); + }, + onError: (err: unknown) => { + const code = getPluginErrorCode(err); + if (code === ErrorCode.GitHubIntegrationNotFound) { + setError("No GitHub token configured for this project."); + return; + } + if (code === ErrorCode.GitHubRepositoryNotFound) { + setError("Repository not found. It may have been unlinked."); + return; + } + if (code === ErrorCode.GitHubBranchNotFound) { + setError( + `Branch "${branchName}" was not found in the selected repository.`, + ); + return; + } + if (code === ErrorCode.GitHubBranchAlreadyLinked) { + setError(`Branch "${branchName}" is already linked to this task.`); + return; + } + if (code === ErrorCode.GitHubTokenInsufficientPermissions) { + setError( + "Your GitHub token does not have permission to read branches. Update it in Project Settings > GitHub.", + ); + return; + } + setError("Failed to link branch. Please try again."); + }, + }); + + function submit() { + if (parsed) { + if (!urlMatchedRepo) { + setError( + `Repository "${parsed.fullName}" is not linked to this project.`, + ); + return; + } + } else { + if (!effectiveRepoId) { + setError("Select a repository."); + return; + } + if (!branchName) { + setError("Enter a branch name or paste a GitHub branch URL."); + return; + } + } + mutation.mutate(); + } + + return ( +
+ {/* Repository selector */} +
+

Repository

+ +
+ + {/* Branch name or URL */} +
+

+ Branch name or GitHub URL +

+ { + setValue(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") submit(); + if (e.key === "Escape") onDone(); + }} + placeholder="feature/foo or https://github.com/owner/repo/tree/feature/foo" + className={cn( + "w-full rounded-md border bg-background px-2.5 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring", + error ? "border-destructive" : "border-border/60", + )} + spellCheck={false} + // biome-ignore lint/a11y/noAutofocus: intentional for inline form + autoFocus + disabled={mutation.isPending} + /> + {parsed && urlMatchedRepo && ( +

+ Will link branch{" "} + + {parsed.branchName} + {" "} + from {urlMatchedRepo.full_name} +

+ )} +
+ + {error && ( +

+ {error} +

+ )} + + {/* Action buttons */} +
+ + +
+
+ ); +} + // ── Branches section ────────────────────────────────────────────────────────── function BranchesSection({ @@ -774,7 +977,7 @@ function BranchesSection({ canEdit: boolean; }) { const [expanded, setExpanded] = useState(true); - const [creating, setCreating] = useState(false); + const [mode, setMode] = useState<"create" | "link" | null>(null); const { data: branches = [], isLoading } = useQuery({ queryKey: taskBranchesKey(projectId, taskId), @@ -828,13 +1031,13 @@ function BranchesSection({ ))} - {count === 0 && !creating && ( + {count === 0 && mode === null && (

No branches linked yet.

)} - {creating ? ( + {mode === "create" && ( setCreating(false)} + onDone={() => setMode(null)} /> - ) : ( - canCreate && ( + )} + + {mode === "link" && ( + setMode(null)} + /> + )} + + {mode === null && canCreate && ( +
- ) + +
)} )} diff --git a/frontend/src/github-api.ts b/frontend/src/github-api.ts index df01629..94ee217 100644 --- a/frontend/src/github-api.ts +++ b/frontend/src/github-api.ts @@ -15,6 +15,7 @@ export const ErrorCode = { GitHubWebhookCreationFailed: "GITHUB_WEBHOOK_CREATION_FAILED", GitHubWebhookURLNotPublic: "GITHUB_WEBHOOK_URL_NOT_PUBLIC", GitHubBranchAlreadyLinked: "GITHUB_BRANCH_ALREADY_LINKED", + GitHubBranchNotFound: "GITHUB_BRANCH_NOT_FOUND", GitHubTokenInsufficientPermissions: "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", BadRequest: "BAD_REQUEST", } as const; @@ -238,3 +239,16 @@ export async function createBranch( { repo_id: repoId, branch_name: branchName, source_branch: sourceBranch }, ); } + +export async function linkBranchToTask( + api: PluginApiClient, + taskId: string, + repoId: string, + branchName: string, +): Promise { + return api.pluginPost( + PLUGIN_ID, + `/projects/${api.projectId}/tasks/${taskId}/branches/link`, + { repo_id: repoId, branch_name: branchName }, + ); +} diff --git a/mcp/bun.lock b/mcp/bun.lock index 56ab01a..59a72f0 100644 --- a/mcp/bun.lock +++ b/mcp/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@paca-ai/github-plugin-mcp", "dependencies": { - "@paca-ai/plugin-sdk-mcp": "^0.1.0", + "@paca-ai/plugin-sdk-mcp": "^0.2.0", }, "devDependencies": { "typescript": "^5.8.3", @@ -70,7 +70,7 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - "@paca-ai/plugin-sdk-mcp": ["@paca-ai/plugin-sdk-mcp@0.1.0", "", { "peerDependencies": { "@modelcontextprotocol/sdk": ">=0.6.0" } }, "sha512-TOFdpdeY7boVRH6EgOhiMj6a0Q6+77nJ2M2Bh7awmN63S2JHA/8sxyK9V8fK0j94mBmnpnoS2SBI52a0valoaQ=="], + "@paca-ai/plugin-sdk-mcp": ["@paca-ai/plugin-sdk-mcp@0.2.0", "", { "peerDependencies": { "@modelcontextprotocol/sdk": ">=0.6.0" } }, "sha512-wpZqKLmfoO98OO2xRfCtrXzfRzJX06yr48LCfu+dE2p1kpNUyWHvp1XkTPOLHTBBOuebMwSDcuZT94u+egv4uA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="], diff --git a/mcp/package.json b/mcp/package.json index d7bb4fa..91e5ce3 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@paca-ai/plugin-sdk-mcp": "^0.1.0" + "@paca-ai/plugin-sdk-mcp": "^0.2.0" }, "devDependencies": { "typescript": "^5.8.3", diff --git a/mcp/src/index.ts b/mcp/src/index.ts index b4ed7a5..8f081ec 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -770,6 +770,51 @@ const entry: PluginMCPEntry = { return errorResult(`GitHub plugin error: ${msg}`); } }, + + async getToolContext( + toolId: string, + args: Record, + context: PluginMCPContext, + ) { + if (toolId !== "get_task") return null; + const { projectId, taskId } = args as { projectId: string; taskId: string }; + + const api = new PluginAPIClient(context); + try { + const [branches, prs] = await Promise.all([ + api.pluginGet( + `projects/${projectId}/tasks/${taskId}/branches`, + ), + api.pluginGet( + `projects/${projectId}/tasks/${taskId}/pull-requests`, + ), + ]); + if (branches.length === 0 && prs.length === 0) return null; + + const lines = ["## GitHub"]; + if (branches.length > 0) { + lines.push( + "", + "**Branches:**", + ...branches.map((b) => `- ${b.branch_name}`), + ); + } + if (prs.length > 0) { + lines.push( + "", + "**Pull Requests:**", + ...prs.map( + (pr) => `- #${pr.pr_number} [${pr.state}] ${pr.title} — ${pr.html_url}`, + ), + ); + } + return lines.join("\n"); + } catch { + // Best-effort enrichment — a transient failure here should not + // break the get_task response for the AI client. + return null; + } + }, }; export default entry; diff --git a/plugin.json b/plugin.json index 0559686..fa88eaf 100644 --- a/plugin.json +++ b/plugin.json @@ -2,8 +2,8 @@ "id": "com.paca.github", "displayName": "GitHub Integration", "description": "Integrates GitHub repositories, pull requests, and branches with Paca projects and tasks.", - "version": "0.3.2", - "minCoreVersion": "v0.11.2", + "version": "0.3.3", + "minCoreVersion": "v0.12.2", "capabilities": ["repository"], "permissions": ["db.read", "db.write"], "backend": { @@ -349,7 +349,8 @@ ] }, "mcp": { - "remoteEntryUrl": "/plugins-mcp/com.paca.github/mcp.js" + "remoteEntryUrl": "/plugins-mcp/com.paca.github/mcp.js", + "toolContextHooks": ["get_task"] }, "skills": { "baseUrl": "/plugins-skills/com.paca.github",