Skip to content

Commit 8d16f3f

Browse files
authored
Merge pull request #13 from Paca-AI/feature/add-pull-request-reviewing-tools
feat: add pull request details and CI status endpoints, along with re…
2 parents 41eebd2 + deac822 commit 8d16f3f

6 files changed

Lines changed: 751 additions & 1 deletion

File tree

backend/client.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type ghPullRequest struct {
3636
HTMLURL string `json:"html_url"`
3737
Head struct {
3838
Ref string `json:"ref"`
39+
SHA string `json:"sha"`
3940
} `json:"head"`
4041
Base struct {
4142
Ref string `json:"ref"`
@@ -47,6 +48,30 @@ type ghPullRequest struct {
4748
MergedAt *time.Time `json:"merged_at"`
4849
}
4950

51+
// ghCombinedStatus is the response of GET /commits/{ref}/status — the legacy
52+
// Status API used by CI integrations that post plain commit statuses rather
53+
// than check runs (e.g. classic CircleCI/Travis integrations).
54+
type ghCombinedStatus struct {
55+
State string `json:"state"` // "pending" | "success" | "failure" | "error"
56+
Statuses []struct {
57+
Context string `json:"context"`
58+
State string `json:"state"`
59+
Description string `json:"description"`
60+
TargetURL string `json:"target_url"`
61+
} `json:"statuses"`
62+
}
63+
64+
// ghCheckRunsResponse is the response of GET /commits/{ref}/check-runs — the
65+
// modern Checks API used by GitHub Actions and most GitHub Apps.
66+
type ghCheckRunsResponse struct {
67+
CheckRuns []struct {
68+
Name string `json:"name"`
69+
Status string `json:"status"` // "queued" | "in_progress" | "completed"
70+
Conclusion *string `json:"conclusion"` // "success" | "failure" | ... | null while not completed
71+
HTMLURL string `json:"html_url"`
72+
} `json:"check_runs"`
73+
}
74+
5075
// ghAPIError carries a non-2xx HTTP status and the GitHub error message.
5176
type ghAPIError struct {
5277
StatusCode int
@@ -301,6 +326,64 @@ func (c *ghClient) createPullRequest(ctx context.Context, owner, repo, title, he
301326
return &pr, nil
302327
}
303328

329+
// getPullRequestDiff fetches the unified diff for a pull request via GitHub's
330+
// diff media type. Unlike get(), the response body is raw diff text, not
331+
// JSON, so it bypasses get()'s json.Unmarshal step.
332+
func (c *ghClient) getPullRequestDiff(_ context.Context, owner, repo string, prNumber int) (string, error) {
333+
url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", ghBaseURL, owner, repo, prNumber)
334+
hdrs := c.headers()
335+
hdrs["Accept"] = "application/vnd.github.v3.diff"
336+
resp, err := plugin.Fetch("GET", url, hdrs, "")
337+
if err != nil {
338+
return "", fmt.Errorf("githubclient: execute request: %w", err)
339+
}
340+
if resp.Status >= 400 {
341+
return "", ghParseAPIError(resp.Status, resp.Body)
342+
}
343+
return resp.Body, nil
344+
}
345+
346+
// createPullRequestReview submits a review on a pull request. event must be
347+
// one of "APPROVE", "REQUEST_CHANGES", or "COMMENT" (GitHub requires a
348+
// non-empty body for the latter two).
349+
func (c *ghClient) createPullRequestReview(ctx context.Context, owner, repo string, prNumber int, event, body string) error {
350+
url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", ghBaseURL, owner, repo, prNumber)
351+
reqBody := map[string]string{"event": event}
352+
if body != "" {
353+
reqBody["body"] = body
354+
}
355+
return c.post(ctx, url, reqBody, nil)
356+
}
357+
358+
// createIssueComment adds a general (non-review) comment to a pull request.
359+
// GitHub represents PR comments as issue comments under /issues/{number}/comments.
360+
func (c *ghClient) createIssueComment(ctx context.Context, owner, repo string, prNumber int, body string) error {
361+
url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", ghBaseURL, owner, repo, prNumber)
362+
return c.post(ctx, url, map[string]string{"body": body}, nil)
363+
}
364+
365+
// getCombinedStatus fetches legacy commit statuses posted for ref (a SHA,
366+
// branch, or tag name).
367+
func (c *ghClient) getCombinedStatus(ctx context.Context, owner, repo, ref string) (*ghCombinedStatus, error) {
368+
url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/status", ghBaseURL, owner, repo, ref)
369+
var status ghCombinedStatus
370+
if err := c.get(ctx, url, &status); err != nil {
371+
return nil, err
372+
}
373+
return &status, nil
374+
}
375+
376+
// getCheckRuns fetches check runs (GitHub Actions and other Checks-API-based
377+
// CI) for ref (a SHA, branch, or tag name).
378+
func (c *ghClient) getCheckRuns(ctx context.Context, owner, repo, ref string) (*ghCheckRunsResponse, error) {
379+
url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/check-runs", ghBaseURL, owner, repo, ref)
380+
var result ghCheckRunsResponse
381+
if err := c.get(ctx, url, &result); err != nil {
382+
return nil, err
383+
}
384+
return &result, nil
385+
}
386+
304387
func (c *ghClient) createBranch(ctx context.Context, owner, repo, newBranch, sourceBranch string) error {
305388
refURL := fmt.Sprintf("%s/repos/%s/%s/git/ref/heads/%s", ghBaseURL, owner, repo, sourceBranch)
306389
var refResp struct {

backend/plugin.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error {
5151
ctx.Route("POST", "/tasks/:taskId/pull-requests", p.createPullRequest)
5252
ctx.Route("POST", "/tasks/:taskId/pull-requests/link", p.linkPRToTask)
5353
ctx.Route("DELETE", "/tasks/:taskId/pull-requests/:prId", p.unlinkPRFromTask)
54+
ctx.Route("GET", "/tasks/:taskId/pull-requests/:prId", p.getPullRequestDetails)
55+
ctx.Route("GET", "/tasks/:taskId/pull-requests/:prId/ci-status", p.getPullRequestCIStatus)
56+
ctx.Route("POST", "/tasks/:taskId/pull-requests/:prId/comments", p.addPullRequestComment)
57+
ctx.Route("POST", "/tasks/:taskId/pull-requests/:prId/reviews", p.createReview)
5458
ctx.Route("GET", "/tasks/:taskId/branches", p.listTaskBranches)
5559
ctx.Route("POST", "/tasks/:taskId/branches", p.createBranch)
5660

backend/plugin_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,132 @@ func TestGetIntegration_NotConnected(t *testing.T) {
8787
t.Fatalf("expected project_id=%s, got %s", testProjectID, env.Data.ProjectID)
8888
}
8989
}
90+
91+
// ── PR review/comment tests ────────────────────────────────────────────────────
92+
//
93+
// These cover the paths reachable without an outbound GitHub API call
94+
// (validation and "PR not linked to this task"). The actual GitHub call
95+
// (ghClient -> plugin.Fetch) always errors outside a WASM build — see
96+
// plugin-sdk-go's native_backends.go — so the happy path for these three
97+
// handlers, like the existing createPullRequest handler, isn't unit-testable
98+
// here and is covered manually / by integration testing instead.
99+
100+
func reqWithPathParams(params map[string]string) plugintest.Request {
101+
r := callerReq()
102+
for k, v := range params {
103+
r.PathParams[k] = v
104+
}
105+
return r
106+
}
107+
108+
func TestGetPullRequestDetails_NotLinkedToTask(t *testing.T) {
109+
tc := setupPlugin(t)
110+
req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"})
111+
res := tc.Call("GET", "/tasks/:taskId/pull-requests/:prId", req)
112+
113+
if res.StatusCode != 404 {
114+
t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString())
115+
}
116+
}
117+
118+
func TestAddPullRequestComment_MissingBody(t *testing.T) {
119+
tc := setupPlugin(t)
120+
req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}).
121+
WithJSONBody(map[string]string{})
122+
res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/comments", req)
123+
124+
if res.StatusCode != 400 {
125+
t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString())
126+
}
127+
}
128+
129+
func TestAddPullRequestComment_NotLinkedToTask(t *testing.T) {
130+
tc := setupPlugin(t)
131+
req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}).
132+
WithJSONBody(map[string]string{"body": "looks good"})
133+
res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/comments", req)
134+
135+
if res.StatusCode != 404 {
136+
t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString())
137+
}
138+
}
139+
140+
func TestCreateReview_InvalidEvent(t *testing.T) {
141+
tc := setupPlugin(t)
142+
req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}).
143+
WithJSONBody(map[string]string{"event": "NOT_A_REAL_EVENT"})
144+
res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/reviews", req)
145+
146+
if res.StatusCode != 400 {
147+
t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString())
148+
}
149+
}
150+
151+
func TestCreateReview_NotLinkedToTask(t *testing.T) {
152+
tc := setupPlugin(t)
153+
req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}).
154+
WithJSONBody(map[string]string{"event": "APPROVE"})
155+
res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/reviews", req)
156+
157+
if res.StatusCode != 404 {
158+
t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString())
159+
}
160+
}
161+
162+
func TestGetPullRequestCIStatus_NotLinkedToTask(t *testing.T) {
163+
tc := setupPlugin(t)
164+
req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"})
165+
res := tc.Call("GET", "/tasks/:taskId/pull-requests/:prId/ci-status", req)
166+
167+
if res.StatusCode != 404 {
168+
t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString())
169+
}
170+
}
171+
172+
// ── overallCIState ─────────────────────────────────────────────────────────────
173+
174+
func TestOverallCIState_NoChecks(t *testing.T) {
175+
if got := overallCIState(nil); got != "unknown" {
176+
t.Fatalf("expected unknown, got %s", got)
177+
}
178+
}
179+
180+
func TestOverallCIState_AllSuccess(t *testing.T) {
181+
checks := []ciCheckResponse{
182+
{Status: "completed", Conclusion: "success"},
183+
{Status: "completed", Conclusion: "neutral"},
184+
}
185+
if got := overallCIState(checks); got != "success" {
186+
t.Fatalf("expected success, got %s", got)
187+
}
188+
}
189+
190+
func TestOverallCIState_OneFailureWins(t *testing.T) {
191+
checks := []ciCheckResponse{
192+
{Status: "completed", Conclusion: "success"},
193+
{Status: "completed", Conclusion: "failure"},
194+
}
195+
if got := overallCIState(checks); got != "failure" {
196+
t.Fatalf("expected failure, got %s", got)
197+
}
198+
}
199+
200+
func TestOverallCIState_StillRunningIsPending(t *testing.T) {
201+
checks := []ciCheckResponse{
202+
{Status: "completed", Conclusion: "success"},
203+
{Status: "in_progress", Conclusion: ""},
204+
}
205+
if got := overallCIState(checks); got != "pending" {
206+
t.Fatalf("expected pending, got %s", got)
207+
}
208+
}
209+
210+
func TestOverallCIState_FailureBeatsPending(t *testing.T) {
211+
checks := []ciCheckResponse{
212+
{Status: "in_progress", Conclusion: ""},
213+
{Status: "completed", Conclusion: "failure"},
214+
}
215+
if got := overallCIState(checks); got != "failure" {
216+
t.Fatalf("expected failure, got %s", got)
217+
}
218+
}

0 commit comments

Comments
 (0)