From c3341f6c8ca0b89ba13b867c23480396a30054c3 Mon Sep 17 00:00:00 2001 From: pikann Date: Thu, 30 Jul 2026 11:59:17 +0000 Subject: [PATCH 1/3] feat: register condition/action automation nodes Contributes this plugin's own Condition and Action node types to the automation graph: - com.paca.github.pr_state (condition): checks whether the task's linked pull request is in a given state (open/merged/closed/none). - com.paca.github.merge_pr (action): merges the task's linked PR via the GitHub API. Adds ghClient.mergePullRequest, which didn't exist before. - com.paca.github.comment_pr (action): posts a comment on the task's linked PR. Registers both via ctx.Condition()/ctx.Action() in plugin.go, and adds the automation block (with configSchemas) to plugin.json. NOTE: go.mod has a temporary 'replace' pointing at the sibling plugin-sdk-go checkout, needed because this PR depends on plugin-sdk-go's not-yet-released Condition/Action SDK support (see Paca-AI/plugin-sdk-go branch feat/automation-condition-action-nodes). Must be swapped for a real pinned version once that SDK change is tagged and released - do not merge with the replace directive still in place. Verified: go vet clean, 8 new tests pass, GOOS=wasip1 GOARCH=wasm build succeeds (produces a working .wasm binary). --- backend/automation.go | 206 +++++++++++++++++++++++++++++++++++++ backend/automation_test.go | 104 +++++++++++++++++++ backend/client.go | 22 ++++ backend/go.mod | 2 + backend/plugin.go | 3 + plugin.json | 51 +++++++++ 6 files changed, 388 insertions(+) create mode 100644 backend/automation.go create mode 100644 backend/automation_test.go diff --git a/backend/automation.go b/backend/automation.go new file mode 100644 index 0000000..120a453 --- /dev/null +++ b/backend/automation.go @@ -0,0 +1,206 @@ +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) — reverse-DNS namespaced under "com.paca.github". +// +// Both handlers resolve the calling project from req.Config.ProjectID +// (embedded in the node's config at graph-authoring time via the config +// form, same as any other plugin node config field) rather than from +// req.Task, 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. + +const ( + // automationConditionPRState checks the linked pull request's state + // (open/closed/merged) against a configured expected value. + automationConditionPRState = "com.paca.github.pr_state" + + // automationActionMergePR merges the linked pull request. + automationActionMergePR = "com.paca.github.merge_pr" + // automationActionCommentPR posts a comment on the linked pull request. + automationActionCommentPR = "com.paca.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 +} + +// automationProjectID extracts the project_id every node config in this +// plugin's automation contributions requires — the automation graph itself +// is scoped to a project, but the plugin's own DB rows are keyed by +// project_id too, so each node config carries it explicitly rather than +// relying on cross-referencing the automation run. +func automationProjectID(config json.RawMessage) (string, error) { + var v struct { + ProjectID string `json:"project_id"` + } + if err := json.Unmarshal(config, &v); err != nil { + return "", err + } + if v.ProjectID == "" { + return "", &appError{code: "GITHUB_MISSING_PROJECT_ID", status: 400, msg: "config.project_id is required"} + } + return v.ProjectID, nil +} + +// ─── Condition: com.paca.github.pr_state ───────────────────────────────────── + +func (p *githubPlugin) conditionPRState(req *plugin.ConditionRequest) plugin.ConditionResult { + var cfg struct { + ProjectID string `json:"project_id"` + ExpectedState string `json:"expected_state"` // "open" | "closed" | "merged" + } + if err := json.Unmarshal(req.Config, &cfg); err != nil || cfg.ProjectID == "" || cfg.ExpectedState == "" { + p.log.Error("github: pr_state condition: invalid config") + return plugin.ConditionResult{Matched: false} + } + + linked, err := p.resolveLinkedPRForAutomation(cfg.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(cfg.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: com.paca.github.merge_pr ───────────────────────────────────────── + +func (p *githubPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionResult { + var cfg struct { + ProjectID string `json:"project_id"` + MergeMethod string `json:"merge_method"` // "merge" | "squash" | "rebase"; defaults to "merge" + } + if err := json.Unmarshal(req.Config, &cfg); err != nil || cfg.ProjectID == "" { + return plugin.ActionResult{Applied: false, Error: "invalid config: project_id is required"} + } + if cfg.MergeMethod == "" { + cfg.MergeMethod = "merge" + } + + linked, err := p.resolveLinkedPRForAutomation(cfg.ProjectID, req.Task.ID) + if err != nil { + return plugin.ActionResult{Applied: false, Error: err.Error()} + } + + token, err := p.decryptToken(cfg.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: com.paca.github.comment_pr ─────────────────────────────────────── + +func (p *githubPlugin) actionCommentPR(req *plugin.ActionRequest) plugin.ActionResult { + var cfg struct { + ProjectID string `json:"project_id"` + Body string `json:"body"` + } + if err := json.Unmarshal(req.Config, &cfg); err != nil || cfg.ProjectID == "" || cfg.Body == "" { + return plugin.ActionResult{Applied: false, Error: "invalid config: project_id and body are required"} + } + + linked, err := p.resolveLinkedPRForAutomation(cfg.ProjectID, req.Task.ID) + if err != nil { + return plugin.ActionResult{Applied: false, Error: err.Error()} + } + + token, err := p.decryptToken(cfg.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..eb5819f --- /dev/null +++ b/backend/automation_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "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. + +func conditionReqWithConfig(cfg any) plugintest.ConditionRequest { + return plugintest.ConditionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}.WithJSONConfig(cfg) +} + +func actionReqWithConfig(cfg any) plugintest.ActionRequest { + return plugintest.ActionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}.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_NoLinkedPR(t *testing.T) { + tc := setupPlugin(t) + cfg := map[string]string{"project_id": testProjectID, "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) + result := tc.RunAction(automationActionMergePR, actionReqWithConfig(map[string]string{})) + if result.Applied { + t.Fatal("expected Applied=false for missing project_id") + } + if result.Error == "" { + t.Fatal("expected an error message for missing project_id") + } +} + +func TestActionMergePR_NoLinkedPR(t *testing.T) { + tc := setupPlugin(t) + cfg := map[string]string{"project_id": testProjectID} + result := tc.RunAction(automationActionMergePR, 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") + } +} + +func TestActionCommentPR_MissingBody(t *testing.T) { + tc := setupPlugin(t) + cfg := map[string]string{"project_id": testProjectID} + result := tc.RunAction(automationActionCommentPR, actionReqWithConfig(cfg)) + 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{"project_id": testProjectID, "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") + } +} + +func TestAutomationProjectID_Missing(t *testing.T) { + raw, _ := json.Marshal(map[string]string{}) + if _, err := automationProjectID(raw); err == nil { + t.Fatal("expected an error for missing project_id") + } +} + +func TestAutomationProjectID_Present(t *testing.T) { + raw, _ := json.Marshal(map[string]string{"project_id": testProjectID}) + got, err := automationProjectID(raw) + if err != nil { + t.Fatal(err) + } + if got != testProjectID { + t.Fatalf("expected %s, got %s", testProjectID, got) + } +} 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..605dba9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,3 +3,5 @@ module github.com/Paca-AI/first-party/github go 1.24 require github.com/Paca-AI/plugin-sdk-go v0.2.0 + +replace github.com/Paca-AI/plugin-sdk-go => ../../plugin-sdk-go 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/plugin.json b/plugin.json index 62f946b..2c17e5c 100644 --- a/plugin.json +++ b/plugin.json @@ -272,6 +272,57 @@ } ] }, + "automation": { + "conditions": [ + { + "type": "com.paca.github.pr_state", + "label": "GitHub: Pull Request State", + "configSchema": { + "type": "object", + "required": ["project_id", "expected_state"], + "properties": { + "project_id": { "type": "string", "title": "Project ID" }, + "expected_state": { + "type": "string", + "title": "Expected State", + "enum": ["open", "closed", "merged"] + } + } + } + } + ], + "actions": [ + { + "type": "com.paca.github.merge_pr", + "label": "GitHub: Merge Pull Request", + "configSchema": { + "type": "object", + "required": ["project_id"], + "properties": { + "project_id": { "type": "string", "title": "Project ID" }, + "merge_method": { + "type": "string", + "title": "Merge Method", + "enum": ["merge", "squash", "rebase"], + "default": "merge" + } + } + } + }, + { + "type": "com.paca.github.comment_pr", + "label": "GitHub: Comment on Pull Request", + "configSchema": { + "type": "object", + "required": ["project_id", "body"], + "properties": { + "project_id": { "type": "string", "title": "Project ID" }, + "body": { "type": "string", "title": "Comment Body", "format": "textarea" } + } + } + } + ] + }, "mcp": { "remoteEntryUrl": "/plugins-mcp/com.paca.github/mcp.js" }, From e6c0c629e27ff7379c38cd98b224f61807aac954 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 3 Aug 2026 10:23:35 +0000 Subject: [PATCH 2/3] feat: update automation node configurations and add PR state change trigger --- backend/automation.go | 73 ++++++++++++++------------------------ backend/automation_test.go | 59 +++++++++++++++--------------- backend/webhook.go | 33 +++++++++++++++-- plugin.json | 31 +++++++++++----- 4 files changed, 109 insertions(+), 87 deletions(-) diff --git a/backend/automation.go b/backend/automation.go index 120a453..eed9ba6 100644 --- a/backend/automation.go +++ b/backend/automation.go @@ -11,24 +11,26 @@ import ( // 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) — reverse-DNS namespaced under "com.paca.github". +// 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.Config.ProjectID -// (embedded in the node's config at graph-authoring time via the config -// form, same as any other plugin node config field) rather than from -// req.Task, 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. +// 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 = "com.paca.github.pr_state" + automationConditionPRState = "github.pr_state" // automationActionMergePR merges the linked pull request. - automationActionMergePR = "com.paca.github.merge_pr" + automationActionMergePR = "github.merge_pr" // automationActionCommentPR posts a comment on the linked pull request. - automationActionCommentPR = "com.paca.github.comment_pr" + automationActionCommentPR = "github.comment_pr" ) // registerAutomationNodes wires this plugin's Condition/Action handlers @@ -77,43 +79,24 @@ func (p *githubPlugin) resolveLinkedPRForAutomation(projectID, taskID string) (* }, nil } -// automationProjectID extracts the project_id every node config in this -// plugin's automation contributions requires — the automation graph itself -// is scoped to a project, but the plugin's own DB rows are keyed by -// project_id too, so each node config carries it explicitly rather than -// relying on cross-referencing the automation run. -func automationProjectID(config json.RawMessage) (string, error) { - var v struct { - ProjectID string `json:"project_id"` - } - if err := json.Unmarshal(config, &v); err != nil { - return "", err - } - if v.ProjectID == "" { - return "", &appError{code: "GITHUB_MISSING_PROJECT_ID", status: 400, msg: "config.project_id is required"} - } - return v.ProjectID, nil -} - -// ─── Condition: com.paca.github.pr_state ───────────────────────────────────── +// ─── Condition: github.pr_state ─────────────────────────────────────────────── func (p *githubPlugin) conditionPRState(req *plugin.ConditionRequest) plugin.ConditionResult { var cfg struct { - ProjectID string `json:"project_id"` ExpectedState string `json:"expected_state"` // "open" | "closed" | "merged" } - if err := json.Unmarshal(req.Config, &cfg); err != nil || cfg.ProjectID == "" || cfg.ExpectedState == "" { + 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(cfg.ProjectID, req.Task.ID) + 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(cfg.ProjectID) + 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} @@ -133,26 +116,25 @@ func (p *githubPlugin) conditionPRState(req *plugin.ConditionRequest) plugin.Con return plugin.ConditionResult{Matched: state == cfg.ExpectedState} } -// ─── Action: com.paca.github.merge_pr ───────────────────────────────────────── +// ─── Action: github.merge_pr ────────────────────────────────────────────────── func (p *githubPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionResult { var cfg struct { - ProjectID string `json:"project_id"` MergeMethod string `json:"merge_method"` // "merge" | "squash" | "rebase"; defaults to "merge" } - if err := json.Unmarshal(req.Config, &cfg); err != nil || cfg.ProjectID == "" { - return plugin.ActionResult{Applied: false, Error: "invalid config: project_id is required"} + 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(cfg.ProjectID, req.Task.ID) + linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) if err != nil { return plugin.ActionResult{Applied: false, Error: err.Error()} } - token, err := p.decryptToken(cfg.ProjectID) + token, err := p.decryptToken(req.ProjectID) if err != nil { return plugin.ActionResult{Applied: false, Error: "decrypt token: " + err.Error()} } @@ -177,23 +159,22 @@ func (p *githubPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionRes return plugin.ActionResult{Applied: true} } -// ─── Action: com.paca.github.comment_pr ─────────────────────────────────────── +// ─── Action: github.comment_pr ──────────────────────────────────────────────── func (p *githubPlugin) actionCommentPR(req *plugin.ActionRequest) plugin.ActionResult { var cfg struct { - ProjectID string `json:"project_id"` - Body string `json:"body"` + Body string `json:"body"` } - if err := json.Unmarshal(req.Config, &cfg); err != nil || cfg.ProjectID == "" || cfg.Body == "" { - return plugin.ActionResult{Applied: false, Error: "invalid config: project_id and body are required"} + 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(cfg.ProjectID, req.Task.ID) + linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) if err != nil { return plugin.ActionResult{Applied: false, Error: err.Error()} } - token, err := p.decryptToken(cfg.ProjectID) + token, err := p.decryptToken(req.ProjectID) if err != nil { return plugin.ActionResult{Applied: false, Error: "decrypt token: " + err.Error()} } diff --git a/backend/automation_test.go b/backend/automation_test.go index eb5819f..88bb01d 100644 --- a/backend/automation_test.go +++ b/backend/automation_test.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "testing" plugin "github.com/Paca-AI/plugin-sdk-go" @@ -12,13 +11,23 @@ import ( // 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}}.WithJSONConfig(cfg) + 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}}.WithJSONConfig(cfg) + return plugintest.ActionRequest{ + Task: plugin.TaskSnapshot{ID: testTaskID}, + ProjectID: testProjectID, + }.WithJSONConfig(cfg) } func TestConditionPRState_MissingConfig(t *testing.T) { @@ -29,9 +38,19 @@ func TestConditionPRState_MissingConfig(t *testing.T) { } } +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{"project_id": testProjectID, "expected_state": "merged"} + 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") @@ -40,9 +59,11 @@ func TestConditionPRState_NoLinkedPR(t *testing.T) { func TestActionMergePR_MissingProjectID(t *testing.T) { tc := setupPlugin(t) - result := tc.RunAction(automationActionMergePR, actionReqWithConfig(map[string]string{})) + 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 for missing project_id") + 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") @@ -51,8 +72,7 @@ func TestActionMergePR_MissingProjectID(t *testing.T) { func TestActionMergePR_NoLinkedPR(t *testing.T) { tc := setupPlugin(t) - cfg := map[string]string{"project_id": testProjectID} - result := tc.RunAction(automationActionMergePR, actionReqWithConfig(cfg)) + result := tc.RunAction(automationActionMergePR, actionReqWithConfig(map[string]string{})) if result.Applied { t.Fatal("expected Applied=false when no PR is linked to the task") } @@ -63,8 +83,7 @@ func TestActionMergePR_NoLinkedPR(t *testing.T) { func TestActionCommentPR_MissingBody(t *testing.T) { tc := setupPlugin(t) - cfg := map[string]string{"project_id": testProjectID} - result := tc.RunAction(automationActionCommentPR, actionReqWithConfig(cfg)) + result := tc.RunAction(automationActionCommentPR, actionReqWithConfig(map[string]string{})) if result.Applied { t.Fatal("expected Applied=false for missing body") } @@ -75,7 +94,7 @@ func TestActionCommentPR_MissingBody(t *testing.T) { func TestActionCommentPR_NoLinkedPR(t *testing.T) { tc := setupPlugin(t) - cfg := map[string]string{"project_id": testProjectID, "body": "looks good"} + 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") @@ -84,21 +103,3 @@ func TestActionCommentPR_NoLinkedPR(t *testing.T) { t.Fatal("expected an error message when no PR is linked") } } - -func TestAutomationProjectID_Missing(t *testing.T) { - raw, _ := json.Marshal(map[string]string{}) - if _, err := automationProjectID(raw); err == nil { - t.Fatal("expected an error for missing project_id") - } -} - -func TestAutomationProjectID_Present(t *testing.T) { - raw, _ := json.Marshal(map[string]string{"project_id": testProjectID}) - got, err := automationProjectID(raw) - if err != nil { - t.Fatal(err) - } - if got != testProjectID { - t.Fatalf("expected %s, got %s", testProjectID, got) - } -} 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 2c17e5c..9e3aab0 100644 --- a/plugin.json +++ b/plugin.json @@ -273,15 +273,31 @@ ] }, "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": "com.paca.github.pr_state", + "type": "github.pr_state", "label": "GitHub: Pull Request State", "configSchema": { "type": "object", - "required": ["project_id", "expected_state"], + "required": ["expected_state"], "properties": { - "project_id": { "type": "string", "title": "Project ID" }, "expected_state": { "type": "string", "title": "Expected State", @@ -293,13 +309,11 @@ ], "actions": [ { - "type": "com.paca.github.merge_pr", + "type": "github.merge_pr", "label": "GitHub: Merge Pull Request", "configSchema": { "type": "object", - "required": ["project_id"], "properties": { - "project_id": { "type": "string", "title": "Project ID" }, "merge_method": { "type": "string", "title": "Merge Method", @@ -310,13 +324,12 @@ } }, { - "type": "com.paca.github.comment_pr", + "type": "github.comment_pr", "label": "GitHub: Comment on Pull Request", "configSchema": { "type": "object", - "required": ["project_id", "body"], + "required": ["body"], "properties": { - "project_id": { "type": "string", "title": "Project ID" }, "body": { "type": "string", "title": "Comment Body", "format": "textarea" } } } From 39869a795d757325da5059cecf5761eaa8b63a33 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 3 Aug 2026 15:20:49 +0000 Subject: [PATCH 3/3] feat: update plugin-sdk-go dependency to v0.3.1 --- backend/go.mod | 4 +--- backend/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 605dba9..085610c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,6 +2,4 @@ module github.com/Paca-AI/first-party/github go 1.24 -require github.com/Paca-AI/plugin-sdk-go v0.2.0 - -replace github.com/Paca-AI/plugin-sdk-go => ../../plugin-sdk-go +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=