Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions backend/branches.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 20 additions & 2 deletions backend/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"

Expand Down Expand Up @@ -384,8 +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 {
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"`
Expand All @@ -395,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,
Expand Down
67 changes: 67 additions & 0 deletions backend/client_url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package main

import "testing"

func TestGhBranchHeadRefURL_EncodesSlashes(t *testing.T) {
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("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)
}
})
}
}
1 change: 1 addition & 0 deletions backend/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions backend/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,61 @@ 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())
}
}

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) {
Expand Down
40 changes: 40 additions & 0 deletions mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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<TaskBranch>(
`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;
Expand Down
15 changes: 13 additions & 2 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]
}
]
},
Expand Down