From af927b348f06e535a6b6e512f99bc861b51408bb Mon Sep 17 00:00:00 2001 From: pikann Date: Tue, 2 Jun 2026 23:48:32 +0700 Subject: [PATCH] feat: refactor GitHub integration routes and update API endpoints - Changed integration routes from "/github" to "/integration" for clarity. - Updated repository-related endpoints to use "/repositories" instead of "/github/linked-repositories". - Added new endpoint for creating pull requests and fetching clone info. - Modified data structures for repositories and pull requests to include additional fields. - Updated frontend API calls to match new backend routes. - Enhanced error handling and logging for pull request creation. --- backend/client.go | 77 ++++++++++++++- backend/integration.go | 105 ++++++++++++++++----- backend/plugin.go | 38 ++++---- backend/plugin_test.go | 2 +- backend/pull_requests.go | 125 +++++++++++++++++++++++- frontend/src/GitHubSettingsTab.tsx | 3 +- frontend/src/github-api.ts | 28 +++--- mcp/src/index.ts | 146 ++++++++++++++++++----------- plugin.json | 55 ++++++++--- 9 files changed, 450 insertions(+), 129 deletions(-) diff --git a/backend/client.go b/backend/client.go index 05f134d..ce77619 100644 --- a/backend/client.go +++ b/backend/client.go @@ -173,20 +173,70 @@ func (c *ghClient) validateToken(ctx context.Context) error { } func (c *ghClient) listRepositories(ctx context.Context) ([]ghRepository, error) { + seen := make(map[string]struct{}) var all []ghRepository - page := 1 - for { - url := fmt.Sprintf("%s/user/repos?affiliation=owner,collaborator&per_page=100&page=%d", ghBaseURL, page) + + addRepo := func(r ghRepository) { + if _, ok := seen[r.FullName]; ok { + return + } + seen[r.FullName] = struct{}{} + all = append(all, r) + } + + // 1. Repos directly accessible to the user (owned + collaborator + org member). + for page := 1; ; page++ { + url := fmt.Sprintf("%s/user/repos?visibility=all&affiliation=owner,collaborator,organization_member&per_page=100&page=%d", ghBaseURL, page) var batch []ghRepository if err := c.get(ctx, url, &batch); err != nil { return nil, err } - all = append(all, batch...) + for _, r := range batch { + addRepo(r) + } + if len(batch) < 100 { + break + } + } + + // 2. List all orgs the user belongs to, then fetch each org's repos + // explicitly. This catches orgs where the membership is private or + // where the token has "read:org" but not full org repo visibility. + var orgs []struct { + Login string `json:"login"` + } + for page := 1; ; page++ { + url := fmt.Sprintf("%s/user/orgs?per_page=100&page=%d", ghBaseURL, page) + var batch []struct { + Login string `json:"login"` + } + if err := c.get(ctx, url, &batch); err != nil { + // Non-fatal: proceed with what we have from /user/repos. + break + } + orgs = append(orgs, batch...) if len(batch) < 100 { break } - page++ } + + for _, org := range orgs { + for page := 1; ; page++ { + url := fmt.Sprintf("%s/orgs/%s/repos?type=all&per_page=100&page=%d", ghBaseURL, org.Login, page) + var batch []ghRepository + if err := c.get(ctx, url, &batch); err != nil { + // Skip orgs where the token lacks access. + break + } + for _, r := range batch { + addRepo(r) + } + if len(batch) < 100 { + break + } + } + } + return all, nil } @@ -234,6 +284,23 @@ func (c *ghClient) getPullRequest(ctx context.Context, owner, repo string, prNum return &pr, nil } +func (c *ghClient) createPullRequest(ctx context.Context, owner, repo, title, head, base, body string) (*ghPullRequest, error) { + url := fmt.Sprintf("%s/repos/%s/%s/pulls", ghBaseURL, owner, repo) + reqBody := map[string]string{ + "title": title, + "head": head, + "base": base, + } + if body != "" { + reqBody["body"] = body + } + var pr ghPullRequest + if err := c.post(ctx, url, reqBody, &pr); err != nil { + return nil, err + } + return &pr, nil +} + 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/integration.go b/backend/integration.go index 3a0eec4..eb7c511 100644 --- a/backend/integration.go +++ b/backend/integration.go @@ -21,7 +21,9 @@ type integrationResponse struct { UpdatedAt *string `json:"updated_at,omitempty"` } -type repoInfoResponse struct { +// accessibleRepoResponse is returned by GET /integration/accessible-repos. +// It reflects data fetched live from the GitHub API. +type accessibleRepoResponse struct { FullName string `json:"full_name"` Owner string `json:"owner"` RepoName string `json:"repo_name"` @@ -29,18 +31,33 @@ type repoInfoResponse struct { Private bool `json:"private"` } -type linkedRepoResponse struct { +// repositoryResponse is the canonical DTO for a project-linked repository. +// Returned by GET /repositories and POST /repositories. +type repositoryResponse struct { ID string `json:"id"` ProjectID string `json:"project_id"` Owner string `json:"owner"` RepoName string `json:"repo_name"` FullName string `json:"full_name"` DefaultBranch string `json:"default_branch"` + CloneURL string `json:"clone_url"` WebhookActive bool `json:"webhook_active"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } +// repoCloneInfo is returned by GET /repositories/:repoId/clone-info. +// It includes a short-lived token for cloning. +type repoCloneInfo struct { + ID string `json:"id"` + FullName string `json:"full_name"` + Owner string `json:"owner"` + RepoName string `json:"repo_name"` + CloneURL string `json:"clone_url"` + Token string `json:"token"` + ExpiresAt float64 `json:"expires_at"` +} + const githubPluginID = "com.paca.github" func webhookURLFromPublicURL(cfg *plugin.Config, projectID string) (string, error) { @@ -69,7 +86,7 @@ func (p *githubPlugin) decryptToken(projectID string) (string, error) { return p.decrypt(enc) } -// ─── GET /github ────────────────────────────────────────────────────────────── +// ─── GET /integration ──────────────────────────────────────────────────────── func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID @@ -96,7 +113,7 @@ func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response) }) } -// ─── POST /github/token ─────────────────────────────────────────────────────── +// ─── POST /integration/token ───────────────────────────────────────────────── func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID @@ -129,12 +146,11 @@ func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) { } now := nowStr() - id := uuid.New().String() _, err = p.db.Exec(` - INSERT INTO github_integrations (id, project_id, access_token_enc, created_at, updated_at) - VALUES ($1, $2, $3, $4, $4) - ON CONFLICT (project_id) DO UPDATE SET access_token_enc = $3, updated_at = $4 - `, id, projectID, enc, now) + INSERT INTO github_integrations (project_id, access_token_enc, created_at, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (project_id) DO UPDATE SET access_token_enc = EXCLUDED.access_token_enc, updated_at = EXCLUDED.updated_at + `, projectID, enc, now, now) if err != nil { apiError(res, 500, "INTERNAL_ERROR", err.Error()) return @@ -160,7 +176,7 @@ func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) { }) } -// ─── DELETE /github/token ───────────────────────────────────────────────────── +// ─── DELETE /integration/token ─────────────────────────────────────────────── func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID @@ -192,9 +208,9 @@ func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) { noContent(res) } -// ─── GET /github/repositories ──────────────────────────────────────────────── +// ─── GET /integration/accessible-repos ────────────────────────────────────── -func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Response) { +func (p *githubPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID token, err := p.decryptToken(projectID) @@ -210,9 +226,9 @@ func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Respons return } - items := make([]repoInfoResponse, len(repos)) + items := make([]accessibleRepoResponse, len(repos)) for i, r := range repos { - items[i] = repoInfoResponse{ + items[i] = accessibleRepoResponse{ FullName: r.FullName, Owner: r.Owner.Login, RepoName: r.Name, @@ -223,9 +239,9 @@ func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Respons ok(res, items) } -// ─── GET /github/linked-repositories ───────────────────────────────────────── +// ─── GET /repositories ─────────────────────────────────────────────────────── -func (p *githubPlugin) listLinkedRepositories(req *plugin.Request, res *plugin.Response) { +func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID result, err := p.db.Query(` @@ -237,16 +253,18 @@ func (p *githubPlugin) listLinkedRepositories(req *plugin.Request, res *plugin.R return } - items := make([]linkedRepoResponse, 0, len(result.Rows)) + items := make([]repositoryResponse, 0, len(result.Rows)) for _, row := range result.Rows { sc := newRowScanner(result.Columns, row) - items = append(items, linkedRepoResponse{ + fullName := sc.str("full_name") + items = append(items, repositoryResponse{ ID: sc.str("id"), ProjectID: sc.str("project_id"), Owner: sc.str("owner"), RepoName: sc.str("repo_name"), - FullName: sc.str("full_name"), + FullName: fullName, DefaultBranch: sc.str("default_branch"), + CloneURL: "https://github.com/" + fullName + ".git", WebhookActive: sc.int64Val("webhook_id") > 0, CreatedAt: sc.str("created_at"), UpdatedAt: sc.str("updated_at"), @@ -255,7 +273,7 @@ func (p *githubPlugin) listLinkedRepositories(req *plugin.Request, res *plugin.R ok(res, items) } -// ─── POST /github/linked-repositories ──────────────────────────────────────── +// ─── POST /repositories ─────────────────────────────────────────────────────── func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID @@ -352,20 +370,21 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) return } - created(res, linkedRepoResponse{ + created(res, repositoryResponse{ ID: repoID, ProjectID: projectID, Owner: ghRepo.Owner.Login, RepoName: ghRepo.Name, FullName: ghRepo.FullName, DefaultBranch: ghRepo.DefaultBranch, + CloneURL: "https://github.com/" + ghRepo.FullName + ".git", WebhookActive: webhookID > 0, CreatedAt: now, UpdatedAt: now, }) } -// ─── DELETE /github/linked-repositories/:repoId ─────────────────────────────── +// ─── DELETE /repositories/:repoId ──────────────────────────────────────────── func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID @@ -405,6 +424,48 @@ func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Respons noContent(res) } +// ─── GET /repositories/:repoId/clone-info ──────────────────────────────────── + +func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + repoID := req.PathParam("repoId") + if repoID == "" { + apiError(res, 400, "BAD_REQUEST", "repoId path parameter is required") + return + } + result, err := p.db.Query( + `SELECT id, full_name, owner, repo_name FROM github_repositories WHERE project_id = $1 AND id = $2`, + projectID, repoID, + ) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if len(result.Rows) == 0 { + apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "repository not found") + return + } + token, err := p.decryptToken(projectID) + if err != nil { + writeAppError(res, err) + return + } + sc := newRowScanner(result.Columns, result.Rows[0]) + fullName := sc.str("full_name") + ok(res, repoCloneInfo{ + ID: sc.str("id"), + FullName: fullName, + Owner: sc.str("owner"), + RepoName: sc.str("repo_name"), + CloneURL: "https://github.com/" + fullName + ".git", + Token: token, + ExpiresAt: 0, + }) +} + +// ─── GET /github/repo-info (REMOVED) ───────────────────────────────────────── +// Replaced by GET /repositories (list all) and GET /repositories/:repoId/clone-info + // ─── Error helpers ──────────────────────────────────────────────────────────── type appError struct { diff --git a/backend/plugin.go b/backend/plugin.go index 5d729a0..0b2093f 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -34,23 +34,27 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error { ctx.On("task.deleted", p.handleTaskDeleted) ctx.On("project.deleted", p.handleProjectDeleted) - // Integration routes (project-scoped) - ctx.Route("GET", "/github", p.getIntegration) - ctx.Route("POST", "/github/token", p.setToken) - ctx.Route("DELETE", "/github/token", p.deleteToken) - ctx.Route("GET", "/github/repositories", p.listRepositories) - ctx.Route("GET", "/github/linked-repositories", p.listLinkedRepositories) - ctx.Route("POST", "/github/linked-repositories", p.linkRepository) - ctx.Route("DELETE", "/github/linked-repositories/:repoId", p.unlinkRepository) - - // Task-scoped routes - ctx.Route("GET", "/tasks/:taskId/github/pull-requests", p.listTaskPRs) - ctx.Route("POST", "/tasks/:taskId/github/pull-requests", p.linkPRToTask) - ctx.Route("DELETE", "/tasks/:taskId/github/pull-requests/:prId", p.unlinkPRFromTask) - ctx.Route("POST", "/tasks/:taskId/github/branches", p.createBranch) - ctx.Route("GET", "/tasks/:taskId/github/branches", p.listTaskBranches) - - // Webhook – public endpoint, GitHub will POST events here. + // ── Integration (GitHub token / connection) ─────────────────────────────── + ctx.Route("GET", "/integration", p.getIntegration) + ctx.Route("POST", "/integration/token", p.setToken) + ctx.Route("DELETE", "/integration/token", p.deleteToken) + ctx.Route("GET", "/integration/accessible-repos", p.listAccessibleRepos) + + // ── Repositories (repos linked to the project) ──────────────────────────── + ctx.Route("GET", "/repositories", p.listRepositories) + ctx.Route("POST", "/repositories", p.linkRepository) + ctx.Route("DELETE", "/repositories/:repoId", p.unlinkRepository) + ctx.Route("GET", "/repositories/:repoId/clone-info", p.getRepoCloneInfo) + + // ── Task resources ──────────────────────────────────────────────────────── + ctx.Route("GET", "/tasks/:taskId/pull-requests", p.listTaskPRs) + ctx.Route("POST", "/tasks/:taskId/pull-requests", p.createPullRequest) + ctx.Route("POST", "/tasks/:taskId/pull-requests/link", p.linkPRToTask) + ctx.Route("DELETE", "/tasks/:taskId/pull-requests/:prId", p.unlinkPRFromTask) + ctx.Route("GET", "/tasks/:taskId/branches", p.listTaskBranches) + ctx.Route("POST", "/tasks/:taskId/branches", p.createBranch) + + // ── Webhook ─────────────────────────────────────────────────────────────── ctx.Route("POST", "/webhook", p.receiveWebhook) return nil diff --git a/backend/plugin_test.go b/backend/plugin_test.go index 6f725bd..6f3d23c 100644 --- a/backend/plugin_test.go +++ b/backend/plugin_test.go @@ -65,7 +65,7 @@ func callerReq() plugintest.Request { func TestGetIntegration_NotConnected(t *testing.T) { tc := setupPlugin(t) - res := tc.Call("GET", "/github", callerReq()) + res := tc.Call("GET", "/integration", callerReq()) if res.StatusCode != 200 { t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString()) diff --git a/backend/pull_requests.go b/backend/pull_requests.go index 35fb5c1..0a9f801 100644 --- a/backend/pull_requests.go +++ b/backend/pull_requests.go @@ -71,7 +71,7 @@ func (p *githubPlugin) listTaskPRs(req *plugin.Request, res *plugin.Response) { ok(res, items) } -// ─── POST /tasks/:taskId/github/pull-requests ───────────────────────────────── +// ─── POST /tasks/:taskId/github/pull-requests/link ─────────────────────────── func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID @@ -214,6 +214,129 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { }) } +// ─── POST /tasks/:taskId/github/pull-requests ───────────────────────────────── + +func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + taskID := req.PathParam("taskId") + + type bodyT struct { + RepoID string `json:"repo_id"` + Title string `json:"title"` + HeadBranch string `json:"head_branch"` + BaseBranch string `json:"base_branch"` + Body string `json:"body"` + } + b, err := plugin.JSONBody[bodyT](req) + if err != nil || b.RepoID == "" || b.Title == "" || b.HeadBranch == "" || b.BaseBranch == "" { + apiError(res, 400, "BAD_REQUEST", "repo_id, title, head_branch, and base_branch 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) + ghPR, err := ghc.createPullRequest(context.Background(), owner, repoName, b.Title, b.HeadBranch, b.BaseBranch, b.Body) + if err != nil { + var apiErr *ghAPIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case 401, 403: + apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to create pull requests") + return + case 422: + apiError(res, 422, "GITHUB_PR_VALIDATION_ERROR", fmt.Sprintf("GitHub validation error: %s", apiErr.Message)) + return + } + } + apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to create pull request: %s", err)) + return + } + + state := ghPR.State + if ghPR.Merged { + state = "merged" + } + + now := nowStr() + prID := uuid.New().String() + + var mergedAtStr *string + if ghPR.MergedAt != nil { + s := ghPR.MergedAt.UTC().Format("2006-01-02T15:04:05.999999999Z") + mergedAtStr = &s + } + + _, err = p.db.Exec(` + INSERT INTO github_pull_requests + (id, project_id, repo_id, pr_number, github_pr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$13) + ON CONFLICT (repo_id, pr_number) DO UPDATE SET + title=$6, state=$7, html_url=$8, head_branch=$9, base_branch=$10, + author=$11, merged_at=$12, updated_at=$13 + `, prID, projectID, b.RepoID, ghPR.Number, ghPR.ID, ghPR.Title, state, + ghPR.HTMLURL, ghPR.Head.Ref, ghPR.Base.Ref, ghPR.User.Login, mergedAtStr, now) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + + linkID := uuid.New().String() + _, lErr := p.db.Exec(` + INSERT INTO github_task_pr_links (id, task_id, pull_request_id, created_at) + VALUES ($1,$2,$3,$4) + ON CONFLICT (task_id, pull_request_id) DO NOTHING + `, linkID, taskID, prID, now) + if lErr != nil { + p.log.Error("failed to link PR to task: " + lErr.Error()) + } + + plugin.EmitEvent("github.pr_created", map[string]any{ + "project_id": projectID, + "task_id": taskID, + "repo_id": b.RepoID, + "pr_number": ghPR.Number, + "pr_url": ghPR.HTMLURL, + }) + + created(res, pullRequestResponse{ + ID: prID, + ProjectID: projectID, + RepoID: b.RepoID, + PRNumber: ghPR.Number, + GitHubPRID: ghPR.ID, + Title: ghPR.Title, + State: state, + HTMLURL: ghPR.HTMLURL, + HeadBranch: ghPR.Head.Ref, + BaseBranch: ghPR.Base.Ref, + Author: ghPR.User.Login, + MergedAt: mergedAtStr, + CreatedAt: now, + UpdatedAt: now, + }) +} + // ─── DELETE /tasks/:taskId/github/pull-requests/:prId ──────────────────────── func (p *githubPlugin) unlinkPRFromTask(req *plugin.Request, res *plugin.Response) { diff --git a/frontend/src/GitHubSettingsTab.tsx b/frontend/src/GitHubSettingsTab.tsx index 12593ae..00a356d 100644 --- a/frontend/src/GitHubSettingsTab.tsx +++ b/frontend/src/GitHubSettingsTab.tsx @@ -386,6 +386,7 @@ function AddRepoDialog({ queryKey: accessibleReposKey(projectId), queryFn: () => listAccessibleRepos(api), enabled: open, + staleTime: 0, }); const [search, setSearch] = useState(""); const [error, setError] = useState(null); @@ -473,7 +474,7 @@ function AddRepoDialog({ disabled={isFetching} className="flex size-9 shrink-0 items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50" onClick={() => - queryClient.invalidateQueries({ + queryClient.refetchQueries({ queryKey: accessibleReposKey(projectId), }) } diff --git a/frontend/src/github-api.ts b/frontend/src/github-api.ts index 3278918..df01629 100644 --- a/frontend/src/github-api.ts +++ b/frontend/src/github-api.ts @@ -67,12 +67,12 @@ export interface AccessibleRepo { export interface LinkedRepository { id: string; project_id: string; - integration_id: string; owner: string; repo_name: string; full_name: string; default_branch: string; - webhook_id: number; + clone_url: string; + webhook_active: boolean; created_at: string; updated_at: string; } @@ -128,26 +128,26 @@ export const taskBranchesKey = (projectId: string, taskId: string) => export async function getGitHubIntegration( api: PluginApiClient, ): Promise { - return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/github`); + return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/integration`); } export async function setGitHubToken( api: PluginApiClient, token: string, ): Promise { - return api.pluginPost(PLUGIN_ID, `/projects/${api.projectId}/github/token`, { + return api.pluginPost(PLUGIN_ID, `/projects/${api.projectId}/integration/token`, { token, }); } export async function deleteGitHubToken(api: PluginApiClient): Promise { - return api.pluginDelete(PLUGIN_ID, `/projects/${api.projectId}/github/token`); + return api.pluginDelete(PLUGIN_ID, `/projects/${api.projectId}/integration/token`); } export async function listAccessibleRepos( api: PluginApiClient, ): Promise { - return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/github/repositories`); + return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/integration/accessible-repos`); } export async function linkRepository( @@ -157,7 +157,7 @@ export async function linkRepository( ): Promise { return api.pluginPost( PLUGIN_ID, - `/projects/${api.projectId}/github/linked-repositories`, + `/projects/${api.projectId}/repositories`, { owner, repo_name: repoName }, ); } @@ -167,7 +167,7 @@ export async function listLinkedRepositories( ): Promise { return api.pluginGet( PLUGIN_ID, - `/projects/${api.projectId}/github/linked-repositories`, + `/projects/${api.projectId}/repositories`, ); } @@ -177,7 +177,7 @@ export async function unlinkRepository( ): Promise { return api.pluginDelete( PLUGIN_ID, - `/projects/${api.projectId}/github/linked-repositories/${repoId}`, + `/projects/${api.projectId}/repositories/${repoId}`, ); } @@ -187,7 +187,7 @@ export async function listTaskPRs( ): Promise { return api.pluginGet( PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/github/pull-requests`, + `/projects/${api.projectId}/tasks/${taskId}/pull-requests`, ); } @@ -199,7 +199,7 @@ export async function linkPRToTask( ): Promise { return api.pluginPost( PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/github/pull-requests`, + `/projects/${api.projectId}/tasks/${taskId}/pull-requests/link`, { repo_id: repoId, pr_number: prNumber }, ); } @@ -211,7 +211,7 @@ export async function unlinkPRFromTask( ): Promise { return api.pluginDelete( PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/github/pull-requests/${prId}`, + `/projects/${api.projectId}/tasks/${taskId}/pull-requests/${prId}`, ); } @@ -221,7 +221,7 @@ export async function listTaskBranches( ): Promise { return api.pluginGet( PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/github/branches`, + `/projects/${api.projectId}/tasks/${taskId}/branches`, ); } @@ -234,7 +234,7 @@ export async function createBranch( ): Promise { return api.pluginPost( PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/github/branches`, + `/projects/${api.projectId}/tasks/${taskId}/branches`, { repo_id: repoId, branch_name: branchName, source_branch: sourceBranch }, ); } diff --git a/mcp/src/index.ts b/mcp/src/index.ts index efd4f1e..34b414d 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -22,20 +22,13 @@ interface LinkedRepository { owner: string; repo_name: string; full_name: string; - private: boolean; default_branch: string; - webhook_id: number | null; + clone_url: string; + webhook_active: boolean; created_at: string; + updated_at: string; } -interface AccessibleRepo { - id: number; - owner: string; - repo_name: string; - full_name: string; - private: boolean; - default_branch: string; -} interface PullRequest { id: string; @@ -65,6 +58,23 @@ interface CreateBranchResult { html_url: string; } +interface CreatePullRequestResult { + id: string; + project_id: string; + repo_id: string; + pr_number: number; + github_pr_id: number; + title: string; + state: string; + html_url: string; + head_branch: string; + base_branch: string; + author: string; + merged_at: string | null; + created_at: string; + updated_at: string; +} + // ── Formatting helpers ──────────────────────────────────────────────────────── function formatIntegration(integration: GitHubIntegration): string { @@ -80,21 +90,12 @@ function formatLinkedRepo(repo: LinkedRepository): string { ID: ${repo.id} Owner: ${repo.owner} Repo Name: ${repo.repo_name} -Full Name: ${repo.full_name} -Private: ${repo.private ? "Yes" : "No"} Default Branch: ${repo.default_branch} -Webhook ID: ${repo.webhook_id ?? "None"} +Clone URL: ${repo.clone_url} +Webhook Active: ${repo.webhook_active ? "Yes" : "No"} Created: ${repo.created_at}`; } -function formatAccessibleRepo(repo: AccessibleRepo): string { - return `Repository: ${repo.full_name} -ID: ${repo.id} -Owner: ${repo.owner} -Private: ${repo.private ? "Yes" : "No"} -Default Branch: ${repo.default_branch}`; -} - function formatPullRequest(pr: PullRequest): string { return `Pull Request: #${pr.pr_number} - ${pr.title} ID: ${pr.id} @@ -178,18 +179,6 @@ const tools: Tool[] = [ }, }, // ── Repositories ───────────────────────────────────────────────────────── - { - name: "github_list_repositories", - description: - "List GitHub repositories accessible with the project's GitHub token. Use this before linking a repository.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - }, - required: ["projectId"], - }, - }, { name: "github_list_linked_repos", description: "List GitHub repositories linked to a project.", @@ -291,6 +280,45 @@ const tools: Tool[] = [ required: ["projectId", "taskId", "prId"], }, }, + { + name: "github_create_pull_request", + description: + "Create a new pull request on GitHub for a task. The pull request will be created on the linked repository and automatically linked to the task. Returns the PR URL.", + 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.", + }, + title: { + type: "string", + description: + "The title for the pull request (e.g., 'feat: add user authentication').", + }, + head_branch: { + type: "string", + description: + "The name of the branch that contains the changes (the source branch, e.g., 'feat/PROJ-42-add-feature').", + }, + base_branch: { + type: "string", + description: + "The name of the branch to merge into (the target branch, e.g., 'main' or 'develop').", + }, + body: { + type: "string", + description: + "The description/body for the pull request in Markdown format (optional).", + }, + }, + required: ["projectId", "taskId", "repoId", "title", "head_branch", "base_branch"], + }, + }, // ── Branches ───────────────────────────────────────────────────────────── { name: "github_create_branch", @@ -353,7 +381,7 @@ const entry: PluginMCPEntry = { case "github_get_integration": { const { projectId } = args as { projectId: string }; const integration = await api.pluginGet( - `projects/${projectId}/github`, + `projects/${projectId}/integration`, ); return textResult(formatIntegration(integration)); } @@ -364,7 +392,7 @@ const entry: PluginMCPEntry = { token: string; }; const integration = await api.pluginPost( - `projects/${projectId}/github/token`, + `projects/${projectId}/integration/token`, { token }, ); return textResult( @@ -374,25 +402,15 @@ const entry: PluginMCPEntry = { case "github_delete_token": { const { projectId } = args as { projectId: string }; - await api.pluginDelete(`projects/${projectId}/github/token`); + await api.pluginDelete(`projects/${projectId}/integration/token`); return textResult("GitHub token deleted successfully."); } // ── Repositories ─────────────────────────────────────────────── - case "github_list_repositories": { - const { projectId } = args as { projectId: string }; - const repos = await api.pluginGet( - `projects/${projectId}/github/repositories`, - ); - return textResult( - `Accessible GitHub Repositories:\n\n${formatList(repos, formatAccessibleRepo)}`, - ); - } - case "github_list_linked_repos": { const { projectId } = args as { projectId: string }; const repos = await api.pluginGet( - `projects/${projectId}/github/linked-repositories`, + `projects/${projectId}/repositories`, ); return textResult( `Linked GitHub Repositories:\n\n${formatList(repos, formatLinkedRepo)}`, @@ -406,7 +424,7 @@ const entry: PluginMCPEntry = { repo_name: string; }; const repo = await api.pluginPost( - `projects/${projectId}/github/linked-repositories`, + `projects/${projectId}/repositories`, { owner, repo_name }, ); return textResult( @@ -420,7 +438,7 @@ const entry: PluginMCPEntry = { repoId: string; }; await api.pluginDelete( - `projects/${projectId}/github/linked-repositories/${repoId}`, + `projects/${projectId}/repositories/${repoId}`, ); return textResult(`Repository ${repoId} unlinked successfully.`); } @@ -432,7 +450,7 @@ const entry: PluginMCPEntry = { taskId: string; }; const prs = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/github/pull-requests`, + `projects/${projectId}/tasks/${taskId}/pull-requests`, ); return textResult( `Pull Requests:\n\n${formatList(prs, formatPullRequest)}`, @@ -447,7 +465,7 @@ const entry: PluginMCPEntry = { pr_number: number; }; const pr = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/github/pull-requests`, + `projects/${projectId}/tasks/${taskId}/pull-requests/link`, { repo_id: repoId, pr_number }, ); return textResult( @@ -462,11 +480,31 @@ const entry: PluginMCPEntry = { prId: string; }; await api.pluginDelete( - `projects/${projectId}/tasks/${taskId}/github/pull-requests/${prId}`, + `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}`, ); return textResult(`Pull request ${prId} unlinked successfully.`); } + case "github_create_pull_request": { + const { projectId, taskId, repoId, title, head_branch, base_branch, body } = + args as { + projectId: string; + taskId: string; + repoId: string; + title: string; + head_branch: string; + base_branch: string; + body?: string; + }; + const pr = await api.pluginPost( + `projects/${projectId}/tasks/${taskId}/pull-requests`, + { repo_id: repoId, title, head_branch, base_branch, body: body ?? "" }, + ); + return textResult( + `Pull request created successfully:\n\n#${pr.pr_number} ${pr.title}\nState: ${pr.state}\nAuthor: ${pr.author}\nHead: ${pr.head_branch} → Base: ${pr.base_branch}\nURL: ${pr.html_url}`, + ); + } + // ── Branches ─────────────────────────────────────────────────── case "github_create_branch": { const { projectId, taskId, repoId, branch_name, source_branch } = @@ -478,7 +516,7 @@ const entry: PluginMCPEntry = { source_branch?: string; }; const result = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/github/branches`, + `projects/${projectId}/tasks/${taskId}/branches`, { repo_id: repoId, branch_name, source_branch }, ); return textResult( @@ -492,7 +530,7 @@ const entry: PluginMCPEntry = { taskId: string; }; const branches = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/github/branches`, + `projects/${projectId}/tasks/${taskId}/branches`, ); return textResult( `Branches:\n\n${formatList(branches, formatBranch)}`, diff --git a/plugin.json b/plugin.json index 36a18bf..c986ce8 100644 --- a/plugin.json +++ b/plugin.json @@ -3,6 +3,7 @@ "displayName": "GitHub Integration", "description": "Integrates GitHub repositories, pull requests, and branches with Paca projects and tasks.", "version": "0.1.2", + "capabilities": ["repository"], "permissions": ["db.read", "db.write"], "backend": { "allowedConfigKeys": ["ENCRYPTION_KEY", "PUBLIC_URL"], @@ -10,7 +11,7 @@ "routes": [ { "method": "GET", - "path": "/projects/:projectId/github", + "path": "/projects/:projectId/integration", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -23,7 +24,7 @@ }, { "method": "POST", - "path": "/projects/:projectId/github/token", + "path": "/projects/:projectId/integration/token", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -36,7 +37,7 @@ }, { "method": "DELETE", - "path": "/projects/:projectId/github/token", + "path": "/projects/:projectId/integration/token", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -49,7 +50,7 @@ }, { "method": "GET", - "path": "/projects/:projectId/github/repositories", + "path": "/projects/:projectId/integration/accessible-repos", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -62,7 +63,7 @@ }, { "method": "GET", - "path": "/projects/:projectId/github/linked-repositories", + "path": "/projects/:projectId/repositories", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -75,7 +76,7 @@ }, { "method": "POST", - "path": "/projects/:projectId/github/linked-repositories", + "path": "/projects/:projectId/repositories", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -88,7 +89,7 @@ }, { "method": "DELETE", - "path": "/projects/:projectId/github/linked-repositories/:repoId", + "path": "/projects/:projectId/repositories/:repoId", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -101,7 +102,20 @@ }, { "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/github/pull-requests", + "path": "/projects/:projectId/repositories/:repoId/clone-info", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requirePermissions", + "scope": "project", + "permissions": ["projects.read"] + } + ] + }, + { + "method": "GET", + "path": "/projects/:projectId/tasks/:taskId/pull-requests", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -114,7 +128,7 @@ }, { "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/github/pull-requests", + "path": "/projects/:projectId/tasks/:taskId/pull-requests", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -126,8 +140,8 @@ ] }, { - "method": "DELETE", - "path": "/projects/:projectId/tasks/:taskId/github/pull-requests/:prId", + "method": "POST", + "path": "/projects/:projectId/tasks/:taskId/pull-requests/link", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -139,8 +153,8 @@ ] }, { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/github/branches", + "method": "DELETE", + "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -153,7 +167,7 @@ }, { "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/github/branches", + "path": "/projects/:projectId/tasks/:taskId/branches", "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, @@ -164,6 +178,19 @@ } ] }, + { + "method": "POST", + "path": "/projects/:projectId/tasks/:taskId/branches", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requirePermissions", + "scope": "project", + "permissions": ["tasks.write"] + } + ] + }, { "method": "POST", "path": "/webhook",