diff --git a/backend/client.go b/backend/client.go index ce77619..82ded95 100644 --- a/backend/client.go +++ b/backend/client.go @@ -36,6 +36,7 @@ type ghPullRequest struct { HTMLURL string `json:"html_url"` Head struct { Ref string `json:"ref"` + SHA string `json:"sha"` } `json:"head"` Base struct { Ref string `json:"ref"` @@ -47,6 +48,30 @@ type ghPullRequest struct { MergedAt *time.Time `json:"merged_at"` } +// ghCombinedStatus is the response of GET /commits/{ref}/status — the legacy +// Status API used by CI integrations that post plain commit statuses rather +// than check runs (e.g. classic CircleCI/Travis integrations). +type ghCombinedStatus struct { + State string `json:"state"` // "pending" | "success" | "failure" | "error" + Statuses []struct { + Context string `json:"context"` + State string `json:"state"` + Description string `json:"description"` + TargetURL string `json:"target_url"` + } `json:"statuses"` +} + +// ghCheckRunsResponse is the response of GET /commits/{ref}/check-runs — the +// modern Checks API used by GitHub Actions and most GitHub Apps. +type ghCheckRunsResponse struct { + CheckRuns []struct { + Name string `json:"name"` + Status string `json:"status"` // "queued" | "in_progress" | "completed" + Conclusion *string `json:"conclusion"` // "success" | "failure" | ... | null while not completed + HTMLURL string `json:"html_url"` + } `json:"check_runs"` +} + // ghAPIError carries a non-2xx HTTP status and the GitHub error message. type ghAPIError struct { StatusCode int @@ -301,6 +326,64 @@ func (c *ghClient) createPullRequest(ctx context.Context, owner, repo, title, he return &pr, nil } +// getPullRequestDiff fetches the unified diff for a pull request via GitHub's +// diff media type. Unlike get(), the response body is raw diff text, not +// JSON, so it bypasses get()'s json.Unmarshal step. +func (c *ghClient) getPullRequestDiff(_ context.Context, owner, repo string, prNumber int) (string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", ghBaseURL, owner, repo, prNumber) + hdrs := c.headers() + hdrs["Accept"] = "application/vnd.github.v3.diff" + resp, err := plugin.Fetch("GET", url, hdrs, "") + if err != nil { + return "", fmt.Errorf("githubclient: execute request: %w", err) + } + if resp.Status >= 400 { + return "", ghParseAPIError(resp.Status, resp.Body) + } + return resp.Body, nil +} + +// createPullRequestReview submits a review on a pull request. event must be +// one of "APPROVE", "REQUEST_CHANGES", or "COMMENT" (GitHub requires a +// non-empty body for the latter two). +func (c *ghClient) createPullRequestReview(ctx context.Context, owner, repo string, prNumber int, event, body string) error { + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", ghBaseURL, owner, repo, prNumber) + reqBody := map[string]string{"event": event} + if body != "" { + reqBody["body"] = body + } + return c.post(ctx, url, reqBody, nil) +} + +// createIssueComment adds a general (non-review) comment to a pull request. +// GitHub represents PR comments as issue comments under /issues/{number}/comments. +func (c *ghClient) createIssueComment(ctx context.Context, owner, repo string, prNumber int, body string) error { + url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", ghBaseURL, owner, repo, prNumber) + return c.post(ctx, url, map[string]string{"body": body}, nil) +} + +// getCombinedStatus fetches legacy commit statuses posted for ref (a SHA, +// branch, or tag name). +func (c *ghClient) getCombinedStatus(ctx context.Context, owner, repo, ref string) (*ghCombinedStatus, error) { + url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/status", ghBaseURL, owner, repo, ref) + var status ghCombinedStatus + if err := c.get(ctx, url, &status); err != nil { + return nil, err + } + return &status, nil +} + +// getCheckRuns fetches check runs (GitHub Actions and other Checks-API-based +// CI) for ref (a SHA, branch, or tag name). +func (c *ghClient) getCheckRuns(ctx context.Context, owner, repo, ref string) (*ghCheckRunsResponse, error) { + url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/check-runs", ghBaseURL, owner, repo, ref) + var result ghCheckRunsResponse + if err := c.get(ctx, url, &result); err != nil { + return nil, err + } + return &result, 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/plugin.go b/backend/plugin.go index 0b2093f..e2e08b2 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -51,6 +51,10 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error { 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/pull-requests/:prId", p.getPullRequestDetails) + ctx.Route("GET", "/tasks/:taskId/pull-requests/:prId/ci-status", p.getPullRequestCIStatus) + ctx.Route("POST", "/tasks/:taskId/pull-requests/:prId/comments", p.addPullRequestComment) + 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) diff --git a/backend/plugin_test.go b/backend/plugin_test.go index 6f3d23c..ee7854a 100644 --- a/backend/plugin_test.go +++ b/backend/plugin_test.go @@ -87,3 +87,132 @@ func TestGetIntegration_NotConnected(t *testing.T) { t.Fatalf("expected project_id=%s, got %s", testProjectID, env.Data.ProjectID) } } + +// ── PR review/comment tests ──────────────────────────────────────────────────── +// +// These cover the paths reachable without an outbound GitHub API call +// (validation and "PR not linked to this task"). The actual GitHub call +// (ghClient -> plugin.Fetch) always errors outside a WASM build — see +// plugin-sdk-go's native_backends.go — so the happy path for these three +// handlers, like the existing createPullRequest handler, isn't unit-testable +// here and is covered manually / by integration testing instead. + +func reqWithPathParams(params map[string]string) plugintest.Request { + r := callerReq() + for k, v := range params { + r.PathParams[k] = v + } + return r +} + +func TestGetPullRequestDetails_NotLinkedToTask(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}) + res := tc.Call("GET", "/tasks/:taskId/pull-requests/:prId", req) + + if res.StatusCode != 404 { + t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestAddPullRequestComment_MissingBody(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). + WithJSONBody(map[string]string{}) + res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/comments", req) + + if res.StatusCode != 400 { + t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestAddPullRequestComment_NotLinkedToTask(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). + WithJSONBody(map[string]string{"body": "looks good"}) + res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/comments", req) + + if res.StatusCode != 404 { + t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestCreateReview_InvalidEvent(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). + WithJSONBody(map[string]string{"event": "NOT_A_REAL_EVENT"}) + res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/reviews", req) + + if res.StatusCode != 400 { + t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestCreateReview_NotLinkedToTask(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). + WithJSONBody(map[string]string{"event": "APPROVE"}) + res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/reviews", req) + + if res.StatusCode != 404 { + t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestGetPullRequestCIStatus_NotLinkedToTask(t *testing.T) { + tc := setupPlugin(t) + req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}) + res := tc.Call("GET", "/tasks/:taskId/pull-requests/:prId/ci-status", req) + + if res.StatusCode != 404 { + t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +// ── overallCIState ───────────────────────────────────────────────────────────── + +func TestOverallCIState_NoChecks(t *testing.T) { + if got := overallCIState(nil); got != "unknown" { + t.Fatalf("expected unknown, got %s", got) + } +} + +func TestOverallCIState_AllSuccess(t *testing.T) { + checks := []ciCheckResponse{ + {Status: "completed", Conclusion: "success"}, + {Status: "completed", Conclusion: "neutral"}, + } + if got := overallCIState(checks); got != "success" { + t.Fatalf("expected success, got %s", got) + } +} + +func TestOverallCIState_OneFailureWins(t *testing.T) { + checks := []ciCheckResponse{ + {Status: "completed", Conclusion: "success"}, + {Status: "completed", Conclusion: "failure"}, + } + if got := overallCIState(checks); got != "failure" { + t.Fatalf("expected failure, got %s", got) + } +} + +func TestOverallCIState_StillRunningIsPending(t *testing.T) { + checks := []ciCheckResponse{ + {Status: "completed", Conclusion: "success"}, + {Status: "in_progress", Conclusion: ""}, + } + if got := overallCIState(checks); got != "pending" { + t.Fatalf("expected pending, got %s", got) + } +} + +func TestOverallCIState_FailureBeatsPending(t *testing.T) { + checks := []ciCheckResponse{ + {Status: "in_progress", Conclusion: ""}, + {Status: "completed", Conclusion: "failure"}, + } + if got := overallCIState(checks); got != "failure" { + t.Fatalf("expected failure, got %s", got) + } +} diff --git a/backend/pull_requests.go b/backend/pull_requests.go index 8aad817..cc51574 100644 --- a/backend/pull_requests.go +++ b/backend/pull_requests.go @@ -351,3 +351,300 @@ func (p *githubPlugin) unlinkPRFromTask(req *plugin.Request, res *plugin.Respons } noContent(res) } + +// ─── Shared: resolve a PR's GitHub coordinates for a task ──────────────────── + +// resolvePRForTask looks up the owner/repo/PR-number GitHub needs to act on a +// PR, verifying in the same query that prID is actually linked to taskID +// within this project. Used by every handler below so a caller can't act on +// a PR outside the task (or project) it claims to be operating in. +func (p *githubPlugin) resolvePRForTask(projectID, taskID, prID string) (owner, repoName string, prNumber int, err error) { + linkResult, lErr := p.db.Query( + `SELECT pull_request_id FROM github_task_pr_links WHERE task_id = $1 AND pull_request_id = $2`, + taskID, prID, + ) + if lErr != nil { + return "", "", 0, lErr + } + if len(linkResult.Rows) == 0 { + return "", "", 0, &appError{code: "GITHUB_PR_LINK_NOT_FOUND", status: 404, msg: "Pull request link not found"} + } + + prResult, pErr := p.db.Query( + `SELECT repo_id, pr_number FROM github_pull_requests WHERE id = $1 AND project_id = $2`, + prID, projectID, + ) + if pErr != nil { + return "", "", 0, pErr + } + if len(prResult.Rows) == 0 { + return "", "", 0, &appError{code: "GITHUB_PR_NOT_FOUND", status: 404, msg: "Pull request not found"} + } + prSc := newRowScanner(prResult.Columns, prResult.Rows[0]) + repoID := prSc.str("repo_id") + prNumber = prSc.intVal("pr_number") + + repoResult, rErr := p.db.Query( + `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, + repoID, projectID, + ) + if rErr != nil { + return "", "", 0, rErr + } + if len(repoResult.Rows) == 0 { + return "", "", 0, &appError{code: "GITHUB_REPOSITORY_NOT_FOUND", status: 404, msg: "Repository not found"} + } + repoSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) + return repoSc.str("owner"), repoSc.str("repo_name"), prNumber, nil +} + +// ─── GET /tasks/:taskId/github/pull-requests/:prId ──────────────────────────── + +type pullRequestDetailsResponse struct { + Owner string `json:"owner"` + RepoName string `json:"repo_name"` + PRNumber int `json:"pr_number"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Diff string `json:"diff"` +} + +func (p *githubPlugin) getPullRequestDetails(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + taskID := req.PathParam("taskId") + prID := req.PathParam("prId") + + owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) + if err != nil { + writeAppError(res, err) + return + } + token, err := p.decryptToken(projectID) + if err != nil { + writeAppError(res, err) + return + } + + ghc := newGHClient(token) + ctx := context.Background() + ghPR, err := ghc.getPullRequest(ctx, owner, repoName, prNumber) + if err != nil { + apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request: %s", err)) + return + } + diff, err := ghc.getPullRequestDiff(ctx, owner, repoName, prNumber) + if err != nil { + apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request diff: %s", err)) + return + } + + ok(res, pullRequestDetailsResponse{ + Owner: owner, + RepoName: repoName, + PRNumber: prNumber, + Title: ghPR.Title, + State: ghPR.State, + HTMLURL: ghPR.HTMLURL, + Diff: diff, + }) +} + +// ─── GET /tasks/:taskId/github/pull-requests/:prId/ci-status ───────────────── + +type ciCheckResponse struct { + Name string `json:"name"` + Status string `json:"status"` // "queued" | "in_progress" | "completed" | "pending" + Conclusion string `json:"conclusion"` // "success" | "failure" | ... | "" while not completed + URL string `json:"url"` +} + +type ciStatusResponse struct { + State string `json:"state"` // "success" | "failure" | "pending" | "unknown" + Checks []ciCheckResponse `json:"checks"` +} + +// overallCIState summarizes a set of checks the same way GitHub's own PR +// merge-box does: any failure wins, otherwise any still-running check makes +// the whole thing pending, otherwise (and only if there's at least one +// check) it's a success. No checks at all is reported as "unknown" rather +// than "success" so the agent doesn't mistake "no CI configured" for "CI +// passed". +func overallCIState(checks []ciCheckResponse) string { + if len(checks) == 0 { + return "unknown" + } + pending := false + for _, c := range checks { + switch c.Conclusion { + case "failure", "timed_out", "cancelled", "action_required", "error": + return "failure" + case "success", "neutral", "skipped": + continue + } + if c.Status != "completed" { + pending = true + } + } + if pending { + return "pending" + } + return "success" +} + +func (p *githubPlugin) getPullRequestCIStatus(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + taskID := req.PathParam("taskId") + prID := req.PathParam("prId") + + owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) + if err != nil { + writeAppError(res, err) + return + } + token, err := p.decryptToken(projectID) + if err != nil { + writeAppError(res, err) + return + } + + ghc := newGHClient(token) + ctx := context.Background() + ghPR, err := ghc.getPullRequest(ctx, owner, repoName, prNumber) + if err != nil { + apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request: %s", err)) + return + } + sha := ghPR.Head.SHA + + checks := make([]ciCheckResponse, 0) + + if combined, cErr := ghc.getCombinedStatus(ctx, owner, repoName, sha); cErr == nil { + for _, s := range combined.Statuses { + checks = append(checks, ciCheckResponse{ + Name: s.Context, + Status: "completed", + Conclusion: s.State, + URL: s.TargetURL, + }) + } + } else { + p.log.Error(fmt.Sprintf("failed to fetch combined status for %s/%s@%s: %s", owner, repoName, sha, cErr)) + } + + if runs, rErr := ghc.getCheckRuns(ctx, owner, repoName, sha); rErr == nil { + for _, r := range runs.CheckRuns { + conclusion := "" + if r.Conclusion != nil { + conclusion = *r.Conclusion + } + checks = append(checks, ciCheckResponse{ + Name: r.Name, + Status: r.Status, + Conclusion: conclusion, + URL: r.HTMLURL, + }) + } + } else { + p.log.Error(fmt.Sprintf("failed to fetch check runs for %s/%s@%s: %s", owner, repoName, sha, rErr)) + } + + ok(res, ciStatusResponse{ + State: overallCIState(checks), + Checks: checks, + }) +} + +// ─── POST /tasks/:taskId/github/pull-requests/:prId/comments ───────────────── + +func (p *githubPlugin) addPullRequestComment(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + taskID := req.PathParam("taskId") + prID := req.PathParam("prId") + + type bodyT struct { + Body string `json:"body"` + } + b, err := plugin.JSONBody[bodyT](req) + if err != nil || b.Body == "" { + apiError(res, 400, "BAD_REQUEST", "body is required") + return + } + + owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) + if err != nil { + writeAppError(res, err) + return + } + token, err := p.decryptToken(projectID) + if err != nil { + writeAppError(res, err) + return + } + + ghc := newGHClient(token) + if err := ghc.createIssueComment(context.Background(), owner, repoName, prNumber, b.Body); err != nil { + var apiErr *ghAPIError + if errors.As(err, &apiErr) && (apiErr.StatusCode == 401 || apiErr.StatusCode == 403) { + apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to comment on pull requests") + return + } + apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to add comment: %s", err)) + return + } + created(res, map[string]bool{"success": true}) +} + +// ─── POST /tasks/:taskId/github/pull-requests/:prId/reviews ────────────────── + +func (p *githubPlugin) createReview(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + taskID := req.PathParam("taskId") + prID := req.PathParam("prId") + + type bodyT struct { + Event string `json:"event"` + Body string `json:"body"` + } + b, err := plugin.JSONBody[bodyT](req) + if err != nil { + apiError(res, 400, "BAD_REQUEST", "event is required") + return + } + switch b.Event { + case "APPROVE", "REQUEST_CHANGES", "COMMENT": + default: + apiError(res, 400, "BAD_REQUEST", "event must be one of: APPROVE, REQUEST_CHANGES, COMMENT") + return + } + + owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) + if err != nil { + writeAppError(res, err) + return + } + token, err := p.decryptToken(projectID) + if err != nil { + writeAppError(res, err) + return + } + + ghc := newGHClient(token) + if err := ghc.createPullRequestReview(context.Background(), owner, repoName, prNumber, b.Event, b.Body); 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 review 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 submit review: %s", err)) + return + } + created(res, map[string]bool{"success": true}) +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 34b414d..d91602e 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -44,6 +44,29 @@ interface PullRequest { created_at: string; } +interface PullRequestDetails { + owner: string; + repo_name: string; + pr_number: number; + title: string; + state: string; + body: string; + html_url: string; + diff: string; +} + +interface CICheck { + name: string; + status: string; + conclusion: string; + url: string; +} + +interface PullRequestCIStatus { + state: string; + checks: CICheck[]; +} + interface TaskBranch { id: string; task_id: string; @@ -107,6 +130,26 @@ Created: ${pr.created_at} Merged: ${pr.merged_at ? `Yes (${pr.merged_at})` : "No"}`; } +function formatPullRequestDetails(pr: PullRequestDetails): string { + return `Pull Request: #${pr.pr_number} - ${pr.title} (${pr.owner}/${pr.repo_name}) +State: ${pr.state} +URL: ${pr.html_url} +Description: ${pr.body || "(none)"} + +Diff: +${pr.diff || "(no changes)"}`; +} + +function formatCIStatus(ci: PullRequestCIStatus): string { + if (ci.checks.length === 0) { + return "CI status: unknown (no checks found for this commit)."; + } + const checkLines = ci.checks + .map((c) => `- ${c.name}: ${c.status}${c.conclusion ? ` (${c.conclusion})` : ""}`) + .join("\n"); + return `CI status: ${ci.state}\n\n${checkLines}`; +} + function formatBranch(branch: TaskBranch): string { return `Branch: ${branch.branch_name} ID: ${branch.id} @@ -319,6 +362,95 @@ const tools: Tool[] = [ required: ["projectId", "taskId", "repoId", "title", "head_branch", "base_branch"], }, }, + { + name: "github_get_pull_request", + description: + "Fetch a pull request's title, description, state, and diff so it can be reviewed.", + inputSchema: { + type: "object", + properties: { + projectId: projectIdProp, + taskId: taskIdProp, + prId: { + type: "string", + description: + UUID_DESC.replace("%s", "linked pull request") + + " Use github_list_task_prs to get the PR ID.", + }, + }, + required: ["projectId", "taskId", "prId"], + }, + }, + { + name: "github_get_pull_request_ci_status", + description: + "Get the CI/check status for a pull request's latest commit (e.g. GitHub Actions runs, other check runs, and legacy commit statuses). Returns an overall state (success, failure, pending, or unknown) plus the individual checks.", + inputSchema: { + type: "object", + properties: { + projectId: projectIdProp, + taskId: taskIdProp, + prId: { + type: "string", + description: + UUID_DESC.replace("%s", "linked pull request") + + " Use github_list_task_prs to get the PR ID.", + }, + }, + required: ["projectId", "taskId", "prId"], + }, + }, + { + name: "github_comment_pull_request", + description: "Add a general (non-review) comment to a pull request.", + inputSchema: { + type: "object", + properties: { + projectId: projectIdProp, + taskId: taskIdProp, + prId: { + type: "string", + description: + UUID_DESC.replace("%s", "linked pull request") + + " Use github_list_task_prs to get the PR ID.", + }, + body: { + type: "string", + description: "The comment text.", + }, + }, + required: ["projectId", "taskId", "prId", "body"], + }, + }, + { + name: "github_review_pull_request", + description: + "Submit a formal review on a pull request (approve, request changes, or comment). Call github_get_pull_request first to see the diff.", + inputSchema: { + type: "object", + properties: { + projectId: projectIdProp, + taskId: taskIdProp, + prId: { + type: "string", + description: + UUID_DESC.replace("%s", "linked pull request") + + " Use github_list_task_prs to get the PR ID.", + }, + event: { + type: "string", + enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"], + description: "The review verdict.", + }, + body: { + type: "string", + description: + "Review summary (required for REQUEST_CHANGES and COMMENT).", + }, + }, + required: ["projectId", "taskId", "prId", "event"], + }, + }, // ── Branches ───────────────────────────────────────────────────────────── { name: "github_create_branch", @@ -505,6 +637,59 @@ const entry: PluginMCPEntry = { ); } + case "github_get_pull_request": { + const { projectId, taskId, prId } = args as { + projectId: string; + taskId: string; + prId: string; + }; + const pr = await api.pluginGet( + `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}`, + ); + return textResult(formatPullRequestDetails(pr)); + } + + case "github_get_pull_request_ci_status": { + const { projectId, taskId, prId } = args as { + projectId: string; + taskId: string; + prId: string; + }; + const ci = await api.pluginGet( + `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}/ci-status`, + ); + return textResult(formatCIStatus(ci)); + } + + case "github_comment_pull_request": { + const { projectId, taskId, prId, body } = args as { + projectId: string; + taskId: string; + prId: string; + body: string; + }; + await api.pluginPost( + `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}/comments`, + { body }, + ); + return textResult("Comment posted successfully."); + } + + case "github_review_pull_request": { + const { projectId, taskId, prId, event, body } = args as { + projectId: string; + taskId: string; + prId: string; + event: string; + body?: string; + }; + await api.pluginPost( + `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}/reviews`, + { event, body: body ?? "" }, + ); + return textResult("Review submitted successfully."); + } + // ── Branches ─────────────────────────────────────────────────── case "github_create_branch": { const { projectId, taskId, repoId, branch_name, source_branch } = diff --git a/plugin.json b/plugin.json index 878f15f..01b30b8 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "id": "com.paca.github", "displayName": "GitHub Integration", "description": "Integrates GitHub repositories, pull requests, and branches with Paca projects and tasks.", - "version": "0.1.8", + "version": "0.2.0", "capabilities": ["repository"], "permissions": ["db.read", "db.write"], "backend": { @@ -165,6 +165,58 @@ } ] }, + { + "method": "GET", + "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requirePermissions", + "scope": "project", + "permissions": ["tasks.read"] + } + ] + }, + { + "method": "GET", + "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId/ci-status", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requirePermissions", + "scope": "project", + "permissions": ["tasks.read"] + } + ] + }, + { + "method": "POST", + "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId/comments", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requirePermissions", + "scope": "project", + "permissions": ["tasks.write"] + } + ] + }, + { + "method": "POST", + "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId/reviews", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requirePermissions", + "scope": "project", + "permissions": ["tasks.write"] + } + ] + }, { "method": "GET", "path": "/projects/:projectId/tasks/:taskId/branches",