Skip to content
Merged
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/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions backend/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
129 changes: 129 additions & 0 deletions backend/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading