diff --git a/backend/automation.go b/backend/automation.go new file mode 100644 index 0000000..eed9ba6 --- /dev/null +++ b/backend/automation.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "encoding/json" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +// This file implements the automation-graph Condition and Action node +// types this plugin contributes, registered in Init via ctx.Condition and +// ctx.Action. Node types must match exactly what's declared in plugin.json +// under "automation" (see AutomationManifest in the core's +// domain/plugin/entity.go) — namespaced under the plugin's short name, +// "github" (the last dot-separated segment of the plugin ID "com.paca.github"), +// not the full reverse-DNS ID. +// +// Both handlers resolve the calling project from req.ProjectID — supplied +// directly by the host (the automation graph's own project), not read from +// the node's config — since a task by itself doesn't carry which GitHub +// repository/PR it's linked to; that's resolved through +// github_task_pr_links the same way the HTTP handlers in pull_requests.go +// do it, keyed by (task_id, project_id). + +const ( + // automationConditionPRState checks the linked pull request's state + // (open/closed/merged) against a configured expected value. + automationConditionPRState = "github.pr_state" + + // automationActionMergePR merges the linked pull request. + automationActionMergePR = "github.merge_pr" + // automationActionCommentPR posts a comment on the linked pull request. + automationActionCommentPR = "github.comment_pr" +) + +// registerAutomationNodes wires this plugin's Condition/Action handlers +// into ctx. Called once from Init. +func (p *githubPlugin) registerAutomationNodes(ctx *plugin.Context) { + ctx.Condition(automationConditionPRState, p.conditionPRState) + ctx.Action(automationActionMergePR, p.actionMergePR) + ctx.Action(automationActionCommentPR, p.actionCommentPR) +} + +// ─── shared: resolve the most recently linked PR for a task ────────────────── + +// pluginLinkedPR is what resolveLinkedPRForAutomation returns: enough to +// call the GitHub API plus the plugin's own repo_id/pr row id for logging. +type pluginLinkedPR struct { + Owner string + RepoName string + PRNumber int +} + +// resolveLinkedPRForAutomation finds the most recently linked PR for a +// task, the same many-PRs-per-task relationship listTaskPRs exposes over +// HTTP — automation nodes act on the newest link since that's virtually +// always the PR the automation graph author means ("the PR for this task"). +func (p *githubPlugin) resolveLinkedPRForAutomation(projectID, taskID string) (*pluginLinkedPR, error) { + result, err := p.db.Query(` + SELECT r.owner, r.repo_name, pr.pr_number + FROM github_pull_requests pr + JOIN github_task_pr_links l ON l.pull_request_id = pr.id + JOIN github_repositories r ON r.id = pr.repo_id + WHERE l.task_id = $1 AND pr.project_id = $2 + ORDER BY l.created_at DESC + LIMIT 1 + `, taskID, projectID) + if err != nil { + return nil, err + } + if len(result.Rows) == 0 { + return nil, &appError{code: "GITHUB_PR_LINK_NOT_FOUND", status: 404, msg: "No pull request linked to this task"} + } + sc := newRowScanner(result.Columns, result.Rows[0]) + return &pluginLinkedPR{ + Owner: sc.str("owner"), + RepoName: sc.str("repo_name"), + PRNumber: sc.intVal("pr_number"), + }, nil +} + +// ─── Condition: github.pr_state ─────────────────────────────────────────────── + +func (p *githubPlugin) conditionPRState(req *plugin.ConditionRequest) plugin.ConditionResult { + var cfg struct { + ExpectedState string `json:"expected_state"` // "open" | "closed" | "merged" + } + if err := json.Unmarshal(req.Config, &cfg); err != nil || req.ProjectID == "" || cfg.ExpectedState == "" { + p.log.Error("github: pr_state condition: invalid config") + return plugin.ConditionResult{Matched: false} + } + + linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) + if err != nil { + p.log.Info("github: pr_state condition: " + err.Error()) + return plugin.ConditionResult{Matched: false} + } + + token, err := p.decryptToken(req.ProjectID) + if err != nil { + p.log.Error("github: pr_state condition: decrypt token: " + err.Error()) + return plugin.ConditionResult{Matched: false} + } + + ghc := newGHClient(token) + ghPR, err := ghc.getPullRequest(context.Background(), linked.Owner, linked.RepoName, linked.PRNumber) + if err != nil { + p.log.Error("github: pr_state condition: fetch PR: " + err.Error()) + return plugin.ConditionResult{Matched: false} + } + + state := ghPR.State + if ghPR.Merged { + state = "merged" + } + return plugin.ConditionResult{Matched: state == cfg.ExpectedState} +} + +// ─── Action: github.merge_pr ────────────────────────────────────────────────── + +func (p *githubPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionResult { + var cfg struct { + MergeMethod string `json:"merge_method"` // "merge" | "squash" | "rebase"; defaults to "merge" + } + if err := json.Unmarshal(req.Config, &cfg); err != nil || req.ProjectID == "" { + return plugin.ActionResult{Applied: false, Error: "invalid config"} + } + if cfg.MergeMethod == "" { + cfg.MergeMethod = "merge" + } + + linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) + if err != nil { + return plugin.ActionResult{Applied: false, Error: err.Error()} + } + + token, err := p.decryptToken(req.ProjectID) + if err != nil { + return plugin.ActionResult{Applied: false, Error: "decrypt token: " + err.Error()} + } + ghc := newGHClient(token) + ctx := context.Background() + + // Idempotency: a plugin action can be retried by the automation + // engine, so check current state first — merging an already-merged PR + // is a no-op success, not an error, mirroring how built-in actions + // treat "already at the desired state". + ghPR, err := ghc.getPullRequest(ctx, linked.Owner, linked.RepoName, linked.PRNumber) + if err != nil { + return plugin.ActionResult{Applied: false, Error: "fetch PR: " + err.Error()} + } + if ghPR.Merged { + return plugin.ActionResult{Applied: false} + } + + if err := ghc.mergePullRequest(ctx, linked.Owner, linked.RepoName, linked.PRNumber, cfg.MergeMethod); err != nil { + return plugin.ActionResult{Applied: false, Error: "merge PR: " + err.Error()} + } + return plugin.ActionResult{Applied: true} +} + +// ─── Action: github.comment_pr ──────────────────────────────────────────────── + +func (p *githubPlugin) actionCommentPR(req *plugin.ActionRequest) plugin.ActionResult { + var cfg struct { + Body string `json:"body"` + } + if err := json.Unmarshal(req.Config, &cfg); err != nil || req.ProjectID == "" || cfg.Body == "" { + return plugin.ActionResult{Applied: false, Error: "invalid config: body is required"} + } + + linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) + if err != nil { + return plugin.ActionResult{Applied: false, Error: err.Error()} + } + + token, err := p.decryptToken(req.ProjectID) + if err != nil { + return plugin.ActionResult{Applied: false, Error: "decrypt token: " + err.Error()} + } + ghc := newGHClient(token) + + if err := ghc.createIssueComment(context.Background(), linked.Owner, linked.RepoName, linked.PRNumber, cfg.Body); err != nil { + return plugin.ActionResult{Applied: false, Error: "comment PR: " + err.Error()} + } + return plugin.ActionResult{Applied: true} +} diff --git a/backend/automation_test.go b/backend/automation_test.go new file mode 100644 index 0000000..88bb01d --- /dev/null +++ b/backend/automation_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "testing" + + plugin "github.com/Paca-AI/plugin-sdk-go" + "github.com/Paca-AI/plugin-sdk-go/plugintest" +) + +// These cover the paths reachable without an outbound GitHub API call: +// config validation and "no PR linked to this task". The GitHub-API-backed +// happy path (like other ghClient-dependent handlers in this plugin) isn't +// unit-testable outside a WASM build — see plugin_test.go's note on this. +// +// ProjectID is supplied the same way the host always supplies it — as a +// top-level request field, not folded into Config — mirroring how +// pluginNodePayload builds a real automation run's request in the core. + +func conditionReqWithConfig(cfg any) plugintest.ConditionRequest { + return plugintest.ConditionRequest{ + Task: plugin.TaskSnapshot{ID: testTaskID}, + ProjectID: testProjectID, + }.WithJSONConfig(cfg) +} + +func actionReqWithConfig(cfg any) plugintest.ActionRequest { + return plugintest.ActionRequest{ + Task: plugin.TaskSnapshot{ID: testTaskID}, + ProjectID: testProjectID, + }.WithJSONConfig(cfg) +} + +func TestConditionPRState_MissingConfig(t *testing.T) { + tc := setupPlugin(t) + result := tc.EvaluateCondition(automationConditionPRState, conditionReqWithConfig(map[string]string{})) + if result.Matched { + t.Fatal("expected Matched=false for missing config") + } +} + +func TestConditionPRState_MissingProjectID(t *testing.T) { + tc := setupPlugin(t) + req := plugintest.ConditionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}. + WithJSONConfig(map[string]string{"expected_state": "merged"}) + result := tc.EvaluateCondition(automationConditionPRState, req) + if result.Matched { + t.Fatal("expected Matched=false when the host supplies no project_id") + } +} + +func TestConditionPRState_NoLinkedPR(t *testing.T) { + tc := setupPlugin(t) + cfg := map[string]string{"expected_state": "merged"} + result := tc.EvaluateCondition(automationConditionPRState, conditionReqWithConfig(cfg)) + if result.Matched { + t.Fatal("expected Matched=false when no PR is linked to the task") + } +} + +func TestActionMergePR_MissingProjectID(t *testing.T) { + tc := setupPlugin(t) + req := plugintest.ActionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}. + WithJSONConfig(map[string]string{}) + result := tc.RunAction(automationActionMergePR, req) + if result.Applied { + t.Fatal("expected Applied=false when the host supplies no project_id") + } + if result.Error == "" { + t.Fatal("expected an error message for missing project_id") + } +} + +func TestActionMergePR_NoLinkedPR(t *testing.T) { + tc := setupPlugin(t) + result := tc.RunAction(automationActionMergePR, actionReqWithConfig(map[string]string{})) + if result.Applied { + t.Fatal("expected Applied=false when no PR is linked to the task") + } + if result.Error == "" { + t.Fatal("expected an error message when no PR is linked") + } +} + +func TestActionCommentPR_MissingBody(t *testing.T) { + tc := setupPlugin(t) + result := tc.RunAction(automationActionCommentPR, actionReqWithConfig(map[string]string{})) + if result.Applied { + t.Fatal("expected Applied=false for missing body") + } + if result.Error == "" { + t.Fatal("expected an error message for missing body") + } +} + +func TestActionCommentPR_NoLinkedPR(t *testing.T) { + tc := setupPlugin(t) + cfg := map[string]string{"body": "looks good"} + result := tc.RunAction(automationActionCommentPR, actionReqWithConfig(cfg)) + if result.Applied { + t.Fatal("expected Applied=false when no PR is linked to the task") + } + if result.Error == "" { + t.Fatal("expected an error message when no PR is linked") + } +} diff --git a/backend/client.go b/backend/client.go index 82ded95..968c1c7 100644 --- a/backend/client.go +++ b/backend/client.go @@ -326,6 +326,28 @@ func (c *ghClient) createPullRequest(ctx context.Context, owner, repo, title, he return &pr, nil } +// mergePullRequest merges a pull request via PUT /pulls/{number}/merge. +// mergeMethod is one of "merge" | "squash" | "rebase" (GitHub defaults to +// "merge" if empty, but callers should always pass an explicit value). +func (c *ghClient) mergePullRequest(ctx context.Context, owner, repo string, prNumber int, mergeMethod string) error { + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/merge", ghBaseURL, owner, repo, prNumber) + body := map[string]string{"merge_method": mergeMethod} + bodyJSON, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("githubclient: encode body: %w", err) + } + hdrs := c.headers() + hdrs["Content-Type"] = "application/json" + resp, err := plugin.Fetch("PUT", url, hdrs, string(bodyJSON)) + if err != nil { + return fmt.Errorf("githubclient: execute request: %w", err) + } + if resp.Status >= 400 { + return ghParseAPIError(resp.Status, resp.Body) + } + return 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. diff --git a/backend/go.mod b/backend/go.mod index 194b2a9..085610c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,4 +2,4 @@ module github.com/Paca-AI/first-party/github go 1.24 -require github.com/Paca-AI/plugin-sdk-go v0.2.0 +require github.com/Paca-AI/plugin-sdk-go v0.3.1 diff --git a/backend/go.sum b/backend/go.sum index 8389b8f..9bb8162 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,2 @@ -github.com/Paca-AI/plugin-sdk-go v0.2.0 h1:Fur6p+OQoC5imq7qmvaQtJnZ3SRVRskx8KcT/rqFHj4= -github.com/Paca-AI/plugin-sdk-go v0.2.0/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= +github.com/Paca-AI/plugin-sdk-go v0.3.1 h1:iwQbGAk1V/7DWmrPXiefKyZtgB68fjFtgJ2Kb5pxxMI= +github.com/Paca-AI/plugin-sdk-go v0.3.1/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= diff --git a/backend/plugin.go b/backend/plugin.go index e2e08b2..5df27dd 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -61,6 +61,9 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error { // ── Webhook ─────────────────────────────────────────────────────────────── ctx.Route("POST", "/webhook", p.receiveWebhook) + // ── Automation graph nodes (Condition/Action) ───────────────────────────── + p.registerAutomationNodes(ctx) + return nil } diff --git a/backend/webhook.go b/backend/webhook.go index 8a20e47..ad1d1be 100644 --- a/backend/webhook.go +++ b/backend/webhook.go @@ -105,6 +105,19 @@ func (p *githubPlugin) handlePREvent(repoID, projectID string, payload []byte) e state = "merged" } + // Read the PR's previously cached state before the upsert overwrites + // it, so we can tell a genuine open/closed/merged transition (the + // github.pr_state_changed automation trigger's source) apart from a + // re-delivered webhook or a non-state-changing action like + // "synchronize"/"labeled", both of which still update the row above but + // shouldn't re-fire an automation. previousState == "" (no existing + // row) means this is the PR's first webhook delivery — not a + // transition, so it never fires pr_state_changed either. + var previousState string + if existing, exErr := p.db.Query(`SELECT state FROM github_pull_requests WHERE repo_id = $1 AND pr_number = $2`, repoID, gh.Number); exErr == nil && len(existing.Rows) > 0 { + previousState = newRowScanner(existing.Columns, existing.Rows[0]).str("state") + } + now := time.Now().UTC().Format(time.RFC3339Nano) var mergedAtStr *string @@ -159,18 +172,32 @@ func (p *githubPlugin) handlePREvent(repoID, projectID string, payload []byte) e } } - // Emit PR updated for all linked tasks. + // Emit PR updated for all linked tasks — and, when the derived + // open/closed/merged state actually transitioned since the last + // webhook delivery, the more specific pr_state_changed event too (the + // github.pr_state_changed automation trigger's source). + stateChanged := previousState != "" && previousState != state linkedResult, _ := p.db.Query(`SELECT task_id FROM github_task_pr_links WHERE pull_request_id = $1`, prID) if linkedResult != nil { for _, row := range linkedResult.Rows { - sc := newRowScanner(linkedResult.Columns, row) + taskID := newRowScanner(linkedResult.Columns, row).str("task_id") plugin.EmitEvent("github.pr_updated", map[string]any{ "project_id": projectID, - "task_id": sc.str("task_id"), + "task_id": taskID, "repo_id": repoID, "pr_number": gh.Number, "action": event.Action, }) + if stateChanged { + plugin.EmitEvent("github.pr_state_changed", map[string]any{ + "project_id": projectID, + "task_id": taskID, + "repo_id": repoID, + "pr_number": gh.Number, + "from_state": previousState, + "to_state": state, + }) + } } } return nil diff --git a/plugin.json b/plugin.json index 62f946b..9e3aab0 100644 --- a/plugin.json +++ b/plugin.json @@ -272,6 +272,70 @@ } ] }, + "automation": { + "triggers": [ + { + "type": "github.pr_linked", + "label": "GitHub: Pull Request Created", + "eventTopic": "github.pr_linked" + }, + { + "type": "github.pr_state_changed", + "label": "GitHub: Pull Request State Changed", + "eventTopic": "github.pr_state_changed" + }, + { + "type": "github.branch_linked", + "label": "GitHub: Branch Created", + "eventTopic": "github.branch_linked" + } + ], + "conditions": [ + { + "type": "github.pr_state", + "label": "GitHub: Pull Request State", + "configSchema": { + "type": "object", + "required": ["expected_state"], + "properties": { + "expected_state": { + "type": "string", + "title": "Expected State", + "enum": ["open", "closed", "merged"] + } + } + } + } + ], + "actions": [ + { + "type": "github.merge_pr", + "label": "GitHub: Merge Pull Request", + "configSchema": { + "type": "object", + "properties": { + "merge_method": { + "type": "string", + "title": "Merge Method", + "enum": ["merge", "squash", "rebase"], + "default": "merge" + } + } + } + }, + { + "type": "github.comment_pr", + "label": "GitHub: Comment on Pull Request", + "configSchema": { + "type": "object", + "required": ["body"], + "properties": { + "body": { "type": "string", "title": "Comment Body", "format": "textarea" } + } + } + } + ] + }, "mcp": { "remoteEntryUrl": "/plugins-mcp/com.paca.github/mcp.js" },