diff --git a/backend/plugin_test.go b/backend/plugin_test.go index 1f19598..3eaf64b 100644 --- a/backend/plugin_test.go +++ b/backend/plugin_test.go @@ -197,3 +197,43 @@ func TestActivityEventDispatchSkipsUnsubscribed(t *testing.T) { t.Fatal("expected delivery to fail outside WASM runtime") } } + +// TestTaskRefAndProjectNameInSummaryText verifies that the human-readable +// "text" summary built for a delivery includes the task's alias (project +// task_id_prefix + task_number, e.g. "ABC-123") and is prefixed with the +// project name. +func TestTaskRefAndProjectNameInSummaryText(t *testing.T) { + p, tc := setupPlugin(t) + + tc.DB.SeedRows("projects", + []string{"id", "name", "task_id_prefix"}, + [][]any{{testProjectID, "Website Redesign", "ABC"}}) + tc.DB.SeedRows("tasks", + []string{"id", "title", "task_number", "project_id"}, + [][]any{{"task-1", "Fix login bug", 123, testProjectID}}) + + payload := map[string]any{ + "project_id": testProjectID, + "task_id": "task-1", + } + + if got, want := p.taskRef(payload), `task ABC-123 "Fix login bug"`; got != want { + t.Fatalf("taskRef = %q, want %q", got, want) + } + if got, want := p.projectName(payload), "Website Redesign"; got != want { + t.Fatalf("projectName = %q, want %q", got, want) + } + + _, text := p.buildEventData("task.deleted", payload) + if want := `Someone deleted task ABC-123 "Fix login bug"`; text != want { + t.Fatalf("buildEventData text = %q, want %q", text, want) + } + + // A project with no task_id_prefix configured falls back to no alias. + tc.DB.SeedRows("projects", + []string{"id", "name", "task_id_prefix"}, + [][]any{{testProjectID, "Website Redesign", ""}}) + if got, want := p.taskRef(payload), `task "Fix login bug"`; got != want { + t.Fatalf("taskRef with no prefix = %q, want %q", got, want) + } +} diff --git a/backend/webhooks.go b/backend/webhooks.go index a5523ba..f5ca53d 100644 --- a/backend/webhooks.go +++ b/backend/webhooks.go @@ -503,10 +503,7 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma Title string `json:"title"` } _ = json.Unmarshal([]byte(rawContent), &c) - if c.Title == "" { - return map[string]any{}, fmt.Sprintf("%s created a task", actor) - } - return map[string]any{"title": c.Title}, fmt.Sprintf("%s created a task: %q", actor, c.Title) + return map[string]any{"title": c.Title}, fmt.Sprintf("%s created %s", actor, p.taskRef(payload)) case "task.updated": ref := p.taskRef(payload) @@ -635,21 +632,48 @@ func (p *webhookPlugin) lookupName(sqlStr, param string) string { return newRowScanner(result.Columns, result.Rows[0]).str("name") } -// taskRef returns a quoted reference to the task ("task \"Fix login bug\"") -// for use in a summary line, falling back to "a task" when the title can't -// be resolved (e.g. task_id missing from the payload). +// taskRef returns a quoted reference to the task, prefixed with its +// human-readable alias when the project has a task ID prefix configured +// (e.g. `task ABC-123 "Fix login bug"`), falling back to "a task" when the +// title can't be resolved (e.g. task_id missing from the payload). func (p *webhookPlugin) taskRef(payload map[string]any) string { taskID, _ := payload["task_id"].(string) if taskID == "" { return "a task" } - title := p.lookupName(`SELECT title AS name FROM tasks WHERE id = $1`, taskID) + result, err := p.db.Query(`SELECT title, task_number, project_id FROM tasks WHERE id = $1`, taskID) + if err != nil || len(result.Rows) == 0 { + return "a task" + } + sc := newRowScanner(result.Columns, result.Rows[0]) + title := sc.str("title") if title == "" { return "a task" } + if alias := p.taskAlias(sc.str("project_id"), sc.intVal("task_number")); alias != "" { + return fmt.Sprintf("task %s %q", alias, title) + } return fmt.Sprintf("task %q", title) } +// taskAlias formats a task's human-readable alias (e.g. "ABC-123") from its +// project's task_id_prefix and the task's sequential task_number, or "" +// when the project has no prefix configured or the task number is unset. +func (p *webhookPlugin) taskAlias(projectID string, taskNumber int) string { + if projectID == "" || taskNumber <= 0 { + return "" + } + result, err := p.db.Query(`SELECT task_id_prefix FROM projects WHERE id = $1`, projectID) + if err != nil || len(result.Rows) == 0 { + return "" + } + prefix := newRowScanner(result.Columns, result.Rows[0]).str("task_id_prefix") + if prefix == "" { + return "" + } + return fmt.Sprintf("%s-%d", prefix, taskNumber) +} + // blockContentToText converts a field-change value (decoded from JSON into // an `any` by fieldChange) back into BlockNote JSON text and extracts plain // text from it. Used for fields like "description" that store BlockNote @@ -701,6 +725,16 @@ func extractBlockText(raw string) string { return "" } +// projectName resolves the display name of the event's project, or "" when +// project_id is missing from the payload or doesn't match a project. +func (p *webhookPlugin) projectName(payload map[string]any) string { + projectID, _ := payload["project_id"].(string) + if projectID == "" { + return "" + } + return p.lookupName(`SELECT name FROM projects WHERE id = $1`, projectID) +} + // taskURL builds a link to the task on the Paca web app, using the host's // PUBLIC_URL config value. Returns "" when PUBLIC_URL isn't configured or // the event has no task_id/project_id (e.g. webhook.test). @@ -725,6 +759,10 @@ func (p *webhookPlugin) deliver(sc *scanner, eventType string, payload map[strin targetURL := sc.str("url") data, text := p.buildEventData(eventType, payload) + if pname := p.projectName(payload); pname != "" { + data["project_name"] = pname + text = fmt.Sprintf("[%s] %s", pname, text) + } if url := p.taskURL(payload); url != "" { data["url"] = url text = text + " - " + url diff --git a/plugin.json b/plugin.json index 773c9e1..d6b9f45 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "id": "com.paca.webhook", "displayName": "Webhooks", "description": "Sends HTTP webhooks to a URL of your choice when task activity happens in a project.", - "version": "0.1.1", + "version": "0.1.2", "permissions": ["db.read", "db.write", "events.subscribe"], "backend": { "allowedConfigKeys": ["ENCRYPTION_KEY", "PUBLIC_URL"],