From 386d949747a1ef193e27229885e1907a311f82df Mon Sep 17 00:00:00 2001 From: Anshul Gada <80207612+Anshulgada@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:25:08 +0530 Subject: [PATCH 1/3] feat: add POST /branches/link to associate existing GitHub branches with tasks Mirrors pull-requests/link: verifies the branch ref on GitHub, inserts into github_task_branches, and exposes github_link_branch_to_task MCP tool. Fixes linking IDE-created branches without calling GitHub create (Reference already exists). Closes #16. --- backend/branches.go | 83 +++++++++++++++++++++++++++++++++++++++++++++ backend/client.go | 5 +++ backend/plugin.go | 1 + mcp/src/index.ts | 40 ++++++++++++++++++++++ plugin.json | 15 ++++++-- 5 files changed, 142 insertions(+), 2 deletions(-) diff --git a/backend/branches.go b/backend/branches.go index 0ff0211..a45dd6b 100644 --- a/backend/branches.go +++ b/backend/branches.go @@ -107,6 +107,89 @@ func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { created(res, createBranchResponse{BranchName: b.BranchName}) } +// ─── POST /tasks/:taskId/branches/link ──────────────────────────────────────── + +func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + taskID := req.PathParam("taskId") + + type bodyT struct { + RepoID string `json:"repo_id"` + BranchName string `json:"branch_name"` + } + b, err := plugin.JSONBody[bodyT](req) + if err != nil || b.RepoID == "" || b.BranchName == "" { + apiError(res, 400, "BAD_REQUEST", "repo_id and branch_name are required") + return + } + + token, err := p.decryptToken(projectID) + if err != nil { + writeAppError(res, err) + return + } + + repoResult, rErr := p.db.Query( + `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, + b.RepoID, projectID, + ) + if rErr != nil { + apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) + return + } + if len(repoResult.Rows) == 0 { + apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") + return + } + rSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) + owner := rSc.str("owner") + repoName := rSc.str("repo_name") + + 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 + } + apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to verify branch: %s", err)) + return + } + + now := nowStr() + inserted, dbErr := p.db.Query(` + INSERT INTO github_task_branches (task_id, repo_id, branch_name, created_at) + VALUES ($1,$2,$3,$4) + ON CONFLICT (task_id, repo_id, branch_name) DO NOTHING + RETURNING id, task_id, repo_id, branch_name, created_at + `, taskID, b.RepoID, b.BranchName, now) + if dbErr != nil { + apiError(res, 500, "INTERNAL_ERROR", dbErr.Error()) + return + } + if len(inserted.Rows) == 0 { + apiError(res, 409, "GITHUB_BRANCH_ALREADY_LINKED", "Branch is already linked to this task") + return + } + + plugin.EmitEvent("github.branch_linked", map[string]any{ + "project_id": projectID, + "task_id": taskID, + "repo_id": b.RepoID, + "branch_name": b.BranchName, + }) + + sc := newRowScanner(inserted.Columns, inserted.Rows[0]) + created(res, taskBranchResponse{ + ID: sc.str("id"), + TaskID: sc.str("task_id"), + RepoID: sc.str("repo_id"), + BranchName: sc.str("branch_name"), + CreatedAt: sc.str("created_at"), + }) +} + // ─── GET /tasks/:taskId/github/branches ─────────────────────────────────────── func (p *githubPlugin) listTaskBranches(req *plugin.Request, res *plugin.Response) { diff --git a/backend/client.go b/backend/client.go index 82ded95..ee08561 100644 --- a/backend/client.go +++ b/backend/client.go @@ -384,6 +384,11 @@ func (c *ghClient) getCheckRuns(ctx context.Context, owner, repo, ref string) (* return &result, nil } +func (c *ghClient) branchExists(ctx context.Context, owner, repo, branch string) error { + refURL := fmt.Sprintf("%s/repos/%s/%s/git/ref/heads/%s", ghBaseURL, owner, repo, branch) + return c.get(ctx, refURL, &struct{}{}) +} + func (c *ghClient) createBranch(ctx context.Context, owner, repo, newBranch, sourceBranch string) error { refURL := fmt.Sprintf("%s/repos/%s/%s/git/ref/heads/%s", ghBaseURL, owner, repo, sourceBranch) var refResp struct { diff --git a/backend/plugin.go b/backend/plugin.go index e2e08b2..c667f34 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -57,6 +57,7 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error { ctx.Route("POST", "/tasks/:taskId/pull-requests/:prId/reviews", p.createReview) ctx.Route("GET", "/tasks/:taskId/branches", p.listTaskBranches) ctx.Route("POST", "/tasks/:taskId/branches", p.createBranch) + ctx.Route("POST", "/tasks/:taskId/branches/link", p.linkBranchToTask) // ── Webhook ─────────────────────────────────────────────────────────────── ctx.Route("POST", "/webhook", p.receiveWebhook) diff --git a/mcp/src/index.ts b/mcp/src/index.ts index d91602e..c8694a6 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -481,6 +481,30 @@ const tools: Tool[] = [ required: ["projectId", "taskId", "repoId", "branch_name"], }, }, + { + name: "github_link_branch_to_task", + description: + "Link an existing GitHub branch to a task (does not create the branch on GitHub).", + inputSchema: { + type: "object", + properties: { + projectId: projectIdProp, + taskId: taskIdProp, + repoId: { + type: "string", + description: + UUID_DESC.replace("%s", "linked repository") + + " Use github_list_linked_repos to get the repo ID.", + }, + branch_name: { + type: "string", + description: + "The existing branch name (e.g., 'fix/upload-pipeline-t8-investigation').", + }, + }, + required: ["projectId", "taskId", "repoId", "branch_name"], + }, + }, { name: "github_list_task_branches", description: "List branches linked to a task.", @@ -709,6 +733,22 @@ const entry: PluginMCPEntry = { ); } + case "github_link_branch_to_task": { + const { projectId, taskId, repoId, branch_name } = args as { + projectId: string; + taskId: string; + repoId: string; + branch_name: string; + }; + const branch = await api.pluginPost( + `projects/${projectId}/tasks/${taskId}/branches/link`, + { repo_id: repoId, branch_name }, + ); + return textResult( + `Branch linked successfully:\n\n${formatBranch(branch)}`, + ); + } + case "github_list_task_branches": { const { projectId, taskId } = args as { projectId: string; diff --git a/plugin.json b/plugin.json index 62f946b..03d064e 100644 --- a/plugin.json +++ b/plugin.json @@ -249,10 +249,21 @@ }, { "method": "POST", - "path": "/webhook", + "path": "/projects/:projectId/tasks/:taskId/branches/link", "middlewares": [ - { "name": "optionalAuthn" } + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requirePermissions", + "scope": "project", + "permissions": ["tasks.write"] + } ] + }, + { + "method": "POST", + "path": "/webhook", + "middlewares": [{ "name": "optionalAuthn" }] } ] }, From c91557326bf42e76cf772f2b9aae5c3c4838a774 Mon Sep 17 00:00:00 2001 From: Anshul Gada <80207612+Anshulgada@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:27:03 +0530 Subject: [PATCH 2/3] fix: URL-encode branch names with slashes in GitHub ref lookups Branch names like fix/foo were split into extra path segments when building GET .../git/ref/heads/{branch}, causing fetch failures that looked like domain allowlist errors. Encode owner, repo, and branch ref segments with url.PathEscape. Adds unit tests for ghBranchHeadRefURL and link-route validation. --- backend/client.go | 21 +++++++++++++++++---- backend/client_url_test.go | 19 +++++++++++++++++++ backend/plugin_test.go | 11 +++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 backend/client_url_test.go diff --git a/backend/client.go b/backend/client.go index ee08561..86c70b8 100644 --- a/backend/client.go +++ b/backend/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "strings" "time" @@ -384,13 +385,25 @@ func (c *ghClient) getCheckRuns(ctx context.Context, owner, repo, ref string) (* return &result, nil } +// ghBranchHeadRefURL builds GET /repos/{owner}/{repo}/git/ref/heads/{branch}. +// Branch names may contain slashes (e.g. fix/foo); encode the ref segment so +// the path is not split into extra URL segments. +func ghBranchHeadRefURL(owner, repo, branch string) string { + return fmt.Sprintf( + "%s/repos/%s/%s/git/ref/heads/%s", + ghBaseURL, + url.PathEscape(owner), + url.PathEscape(repo), + url.PathEscape(branch), + ) +} + func (c *ghClient) branchExists(ctx context.Context, owner, repo, branch string) error { - refURL := fmt.Sprintf("%s/repos/%s/%s/git/ref/heads/%s", ghBaseURL, owner, repo, branch) - return c.get(ctx, refURL, &struct{}{}) + return c.get(ctx, ghBranchHeadRefURL(owner, repo, branch), &struct{}{}) } func (c *ghClient) createBranch(ctx context.Context, owner, repo, newBranch, sourceBranch string) error { - refURL := fmt.Sprintf("%s/repos/%s/%s/git/ref/heads/%s", ghBaseURL, owner, repo, sourceBranch) + refURL := ghBranchHeadRefURL(owner, repo, sourceBranch) var refResp struct { Object struct { SHA string `json:"sha"` @@ -400,7 +413,7 @@ func (c *ghClient) createBranch(ctx context.Context, owner, repo, newBranch, sou return fmt.Errorf("resolve source branch %q: %w", sourceBranch, err) } - createURL := fmt.Sprintf("%s/repos/%s/%s/git/refs", ghBaseURL, owner, repo) + createURL := fmt.Sprintf("%s/repos/%s/%s/git/refs", ghBaseURL, url.PathEscape(owner), url.PathEscape(repo)) return c.post(ctx, createURL, map[string]string{ "ref": "refs/heads/" + newBranch, "sha": refResp.Object.SHA, diff --git a/backend/client_url_test.go b/backend/client_url_test.go new file mode 100644 index 0000000..ff9a7b8 --- /dev/null +++ b/backend/client_url_test.go @@ -0,0 +1,19 @@ +package main + +import "testing" + +func TestGhBranchHeadRefURL_EncodesSlashes(t *testing.T) { + got := ghBranchHeadRefURL("Paca-AI", "paca-plugin-github", "fix/upload-pipeline-t8-investigation") + want := "https://api.github.com/repos/Paca-AI/paca-plugin-github/git/ref/heads/fix%2Fupload-pipeline-t8-investigation" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestGhBranchHeadRefURL_PlainBranch(t *testing.T) { + got := ghBranchHeadRefURL("owner", "repo", "main") + want := "https://api.github.com/repos/owner/repo/git/ref/heads/main" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/backend/plugin_test.go b/backend/plugin_test.go index ee7854a..0d33823 100644 --- a/backend/plugin_test.go +++ b/backend/plugin_test.go @@ -169,6 +169,17 @@ func TestGetPullRequestCIStatus_NotLinkedToTask(t *testing.T) { } } +func TestLinkBranchToTask_MissingBody(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID}). + WithJSONBody(map[string]string{}) + res := tc.Call("POST", "/tasks/:taskId/branches/link", req) + + if res.StatusCode != 400 { + t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) + } +} + // ── overallCIState ───────────────────────────────────────────────────────────── func TestOverallCIState_NoChecks(t *testing.T) { From 8395594703982006882408a0cdb46ffe4c6a9aab Mon Sep 17 00:00:00 2001 From: Anshul Gada <80207612+Anshulgada@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:41:05 +0530 Subject: [PATCH 3/3] test: use public FOSS fixtures and expand branch link validation Replace org-internal repo/branch names in URL tests with public projects (torvalds/linux, golang/go, etc.). Add table-driven slash encoding cases. Add link/create branch 400-path validation tests. --- backend/client_url_test.go | 56 +++++++++++++++++++++++++++++++++++--- backend/plugin_test.go | 44 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/backend/client_url_test.go b/backend/client_url_test.go index ff9a7b8..d750f89 100644 --- a/backend/client_url_test.go +++ b/backend/client_url_test.go @@ -3,17 +3,65 @@ package main import "testing" func TestGhBranchHeadRefURL_EncodesSlashes(t *testing.T) { - got := ghBranchHeadRefURL("Paca-AI", "paca-plugin-github", "fix/upload-pipeline-t8-investigation") - want := "https://api.github.com/repos/Paca-AI/paca-plugin-github/git/ref/heads/fix%2Fupload-pipeline-t8-investigation" + got := ghBranchHeadRefURL("torvalds", "linux", "fix/sched-fair") + want := "https://api.github.com/repos/torvalds/linux/git/ref/heads/fix%2Fsched-fair" if got != want { t.Fatalf("got %q, want %q", got, want) } } func TestGhBranchHeadRefURL_PlainBranch(t *testing.T) { - got := ghBranchHeadRefURL("owner", "repo", "main") - want := "https://api.github.com/repos/owner/repo/git/ref/heads/main" + got := ghBranchHeadRefURL("golang", "go", "master") + want := "https://api.github.com/repos/golang/go/git/ref/heads/master" if got != want { t.Fatalf("got %q, want %q", got, want) } } + +func TestGhBranchHeadRefURL_TableDriven(t *testing.T) { + tests := []struct { + name string + owner string + repo string + branch string + want string + }{ + { + name: "release branch", + owner: "nodejs", + repo: "node", + branch: "v20.x", + want: "https://api.github.com/repos/nodejs/node/git/ref/heads/v20.x", + }, + { + name: "nested feature branch", + owner: "rust-lang", + repo: "rust", + branch: "feature/const-traits", + want: "https://api.github.com/repos/rust-lang/rust/git/ref/heads/feature%2Fconst-traits", + }, + { + name: "dependabot style branch", + owner: "django", + repo: "django", + branch: "dependabot/pip/wheel-0.43.0", + want: "https://api.github.com/repos/django/django/git/ref/heads/dependabot%2Fpip%2Fwheel-0.43.0", + }, + { + name: "owner repo with hyphen", + owner: "home-assistant", + repo: "core", + branch: "dev", + want: "https://api.github.com/repos/home-assistant/core/git/ref/heads/dev", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ghBranchHeadRefURL(tt.owner, tt.repo, tt.branch) + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/backend/plugin_test.go b/backend/plugin_test.go index 0d33823..94c8cca 100644 --- a/backend/plugin_test.go +++ b/backend/plugin_test.go @@ -180,6 +180,50 @@ func TestLinkBranchToTask_MissingBody(t *testing.T) { } } +func TestLinkBranchToTask_MissingRepoID(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID}). + WithJSONBody(map[string]string{"branch_name": "dev"}) + res := tc.Call("POST", "/tasks/:taskId/branches/link", req) + + if res.StatusCode != 400 { + t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestLinkBranchToTask_MissingBranchName(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID}). + WithJSONBody(map[string]string{"repo_id": "00000000-0000-0000-0000-000000000001"}) + res := tc.Call("POST", "/tasks/:taskId/branches/link", req) + + if res.StatusCode != 400 { + t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestCreateBranch_MissingBranchName(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID}). + WithJSONBody(map[string]string{"repo_id": "00000000-0000-0000-0000-000000000001"}) + res := tc.Call("POST", "/tasks/:taskId/branches", req) + + if res.StatusCode != 400 { + t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestCreateBranch_MissingRepoID(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID}). + WithJSONBody(map[string]string{"branch_name": "dev"}) + res := tc.Call("POST", "/tasks/:taskId/branches", req) + + if res.StatusCode != 400 { + t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) + } +} + // ── overallCIState ───────────────────────────────────────────────────────────── func TestOverallCIState_NoChecks(t *testing.T) {