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
187 changes: 187 additions & 0 deletions backend/automation.go
Original file line number Diff line number Diff line change
@@ -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}
}
105 changes: 105 additions & 0 deletions backend/automation_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
22 changes: 22 additions & 0 deletions backend/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -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=
3 changes: 3 additions & 0 deletions backend/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
33 changes: 30 additions & 3 deletions backend/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading