From 58cc84d193d937996968f67d3f2c744a2cf56fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 29 Aug 2026 07:47:01 +0000 Subject: [PATCH] fix: preserve Jira watcher descriptions (#3130) (#3132) --- apps/backend/internal/jira/client.go | 1 + apps/backend/internal/jira/cloud_client.go | 28 +++- .../internal/jira/cloud_client_test.go | 135 ++++++++++++++++++ apps/backend/internal/jira/mcp_client.go | 8 ++ apps/backend/internal/jira/mock_client.go | 6 + .../internal/jira/service_issue_watch_test.go | 45 +++++- apps/backend/internal/jira/service_test.go | 8 ++ apps/backend/internal/jira/service_watch.go | 2 +- .../orchestrator/event_handlers_jira_test.go | 13 +- .../fix-jira-watcher-description/plan.md | 99 +++++++++++++ ...k-01-preserve-jira-watcher-descriptions.md | 102 +++++++++++++ docs/specs/integrations/README.md | 2 + .../requirements/jira-watcher-task-prompts.md | 34 +++++ .../jira-watcher-task-prompts.md | 72 ++++++++++ 14 files changed, 545 insertions(+), 10 deletions(-) create mode 100644 docs/plans/fix-jira-watcher-description/plan.md create mode 100644 docs/plans/fix-jira-watcher-description/task-01-preserve-jira-watcher-descriptions.md create mode 100644 docs/specs/integrations/requirements/jira-watcher-task-prompts.md create mode 100644 docs/specs/integrations/system-design/jira-watcher-task-prompts.md diff --git a/apps/backend/internal/jira/client.go b/apps/backend/internal/jira/client.go index 7c3112b1a1..6dee46cec4 100644 --- a/apps/backend/internal/jira/client.go +++ b/apps/backend/internal/jira/client.go @@ -20,6 +20,7 @@ type Client interface { ListProjects(ctx context.Context) ([]JiraProject, error) ListProjectStatuses(ctx context.Context, projectKey string) ([]JiraStatus, error) SearchTickets(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) + SearchTicketsForWatch(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) } // APIError captures an upstream non-2xx response so handlers can surface a diff --git a/apps/backend/internal/jira/cloud_client.go b/apps/backend/internal/jira/cloud_client.go index 62329d66af..fa11b5b76a 100644 --- a/apps/backend/internal/jira/cloud_client.go +++ b/apps/backend/internal/jira/cloud_client.go @@ -525,6 +525,11 @@ var searchFields = []string{ "priority", "assignee", "reporter", jiraFieldUpdated, } +var watcherSearchFields = []string{ + "summary", "status", "project", "issuetype", + "priority", "assignee", "reporter", jiraFieldUpdated, "description", +} + // SearchTickets runs a JQL search and returns a page of tickets. The transport // branches on instance type because Cloud and Server/DC expose different // search endpoints with incompatible pagination shapes: @@ -538,6 +543,17 @@ var searchFields = []string{ // pageToken is the cursor returned in the previous page's NextPageToken; // pass "" for the first page. maxResults is capped at 100. func (c *CloudClient) SearchTickets(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) { + return c.searchTickets(ctx, jql, pageToken, maxResults, searchFields) +} + +// SearchTicketsForWatch runs a JQL search with the description field included +// for watcher-created task prompts. It uses the same endpoint and pagination +// behavior as SearchTickets while keeping the interactive field set compact. +func (c *CloudClient) SearchTicketsForWatch(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) { + return c.searchTickets(ctx, jql, pageToken, maxResults, watcherSearchFields) +} + +func (c *CloudClient) searchTickets(ctx context.Context, jql, pageToken string, maxResults int, fields []string) (*SearchResult, error) { if maxResults <= 0 { maxResults = 25 } @@ -545,16 +561,16 @@ func (c *CloudClient) SearchTickets(ctx context.Context, jql, pageToken string, maxResults = 100 } if c.instanceType == InstanceTypeServer { - return c.searchTicketsServer(ctx, jql, pageToken, maxResults) + return c.searchTicketsServer(ctx, jql, pageToken, maxResults, fields) } - return c.searchTicketsCloud(ctx, jql, pageToken, maxResults) + return c.searchTicketsCloud(ctx, jql, pageToken, maxResults, fields) } -func (c *CloudClient) searchTicketsCloud(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) { +func (c *CloudClient) searchTicketsCloud(ctx context.Context, jql, pageToken string, maxResults int, fields []string) (*SearchResult, error) { body := map[string]interface{}{ "jql": jql, "maxResults": maxResults, - "fields": searchFields, + "fields": fields, } if pageToken != "" { body["nextPageToken"] = pageToken @@ -575,7 +591,7 @@ func (c *CloudClient) searchTicketsCloud(ctx context.Context, jql, pageToken str return out, nil } -func (c *CloudClient) searchTicketsServer(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) { +func (c *CloudClient) searchTicketsServer(ctx context.Context, jql, pageToken string, maxResults int, fields []string) (*SearchResult, error) { startAt := 0 if pageToken != "" { // A malformed token is fixed by starting over from the first page — @@ -588,7 +604,7 @@ func (c *CloudClient) searchTicketsServer(ctx context.Context, jql, pageToken st "jql": jql, "startAt": startAt, "maxResults": maxResults, - "fields": searchFields, + "fields": fields, } var resp serverSearchResponse if err := c.do(ctx, http.MethodPost, c.apiBase+"/search", body, &resp); err != nil { diff --git a/apps/backend/internal/jira/cloud_client_test.go b/apps/backend/internal/jira/cloud_client_test.go index 0bf9193279..54e03fd473 100644 --- a/apps/backend/internal/jira/cloud_client_test.go +++ b/apps/backend/internal/jira/cloud_client_test.go @@ -3,6 +3,7 @@ package jira import ( "context" "encoding/base64" + "encoding/json" "errors" "io" "net/http" @@ -396,6 +397,140 @@ func TestCloudClient_ServerMode_SearchTickets_StartAtPagination(t *testing.T) { } } +func TestCloudClient_SearchTicketsForWatch_RequestsAndConvertsDescription(t *testing.T) { + tests := []struct { + name string + newClient func(*httptest.Server) *CloudClient + path string + responseBody string + want string + }{ + { + name: "cloud ADF", + newClient: func(ts *httptest.Server) *CloudClient { return clientTo(ts, AuthMethodAPIToken, "tok") }, + path: "/rest/api/3/search/jql", + responseBody: `{ + "issues": [{"key":"C-1","fields": { + "summary":"cloud issue", + "description":{"type":"doc","content":[ + {"type":"paragraph","content":[{"type":"text","text":"first"}]}, + {"type":"paragraph","content":[{"type":"text","text":"second"}]} + ]}, + "status":{},"project":{},"issuetype":{} + }}], + "isLast":true + }`, + want: "first\n\nsecond", + }, + { + name: "server plain string", + newClient: func(ts *httptest.Server) *CloudClient { return serverClient(ts, AuthMethodPAT, "tok") }, + path: "/rest/api/2/search", + responseBody: `{ + "startAt":0,"maxResults":50,"total":1, + "issues": [{"key":"S-1","fields": { + "summary":"server issue","description":"plain description", + "status":{},"project":{},"issuetype":{} + }}] + }`, + want: "plain description", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var fields []string + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tt.path { + t.Errorf("path = %q, want %q", r.URL.Path, tt.path) + } + var request struct { + Fields []string `json:"fields"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + fields = request.Fields + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.responseBody)) + }) + + result, err := tt.newClient(ts).SearchTicketsForWatch( + context.Background(), "project = P", "", 50, + ) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(result.Tickets) != 1 { + t.Fatalf("tickets = %d, want 1", len(result.Tickets)) + } + if result.Tickets[0].Description != tt.want { + t.Errorf("description = %q, want %q", result.Tickets[0].Description, tt.want) + } + if !containsString(fields, "description") { + t.Errorf("watcher fields = %v, want description", fields) + } + }) + } +} + +func TestCloudClient_SearchTickets_KeepsDescriptionOutOfCompactFields(t *testing.T) { + tests := []struct { + name string + newClient func(*httptest.Server) *CloudClient + path string + response string + }{ + { + name: "cloud", + newClient: func(ts *httptest.Server) *CloudClient { return clientTo(ts, AuthMethodAPIToken, "tok") }, + path: "/rest/api/3/search/jql", + response: `{"issues":[],"isLast":true}`, + }, + { + name: "server", + newClient: func(ts *httptest.Server) *CloudClient { return serverClient(ts, AuthMethodPAT, "tok") }, + path: "/rest/api/2/search", + response: `{"startAt":0,"maxResults":50,"total":0,"issues":[]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var fields []string + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tt.path { + t.Errorf("path = %q, want %q", r.URL.Path, tt.path) + } + var request struct { + Fields []string `json:"fields"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + fields = request.Fields + _, _ = w.Write([]byte(tt.response)) + }) + + if _, err := tt.newClient(ts).SearchTickets(context.Background(), "project = P", "", 50); err != nil { + t.Fatalf("search: %v", err) + } + if containsString(fields, "description") { + t.Errorf("compact fields = %v, must not include description", fields) + } + }) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + func TestCloudClient_SearchTickets_PreservesImmutableIssueID(t *testing.T) { ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/apps/backend/internal/jira/mcp_client.go b/apps/backend/internal/jira/mcp_client.go index 431cf9f009..51ed1b9e52 100644 --- a/apps/backend/internal/jira/mcp_client.go +++ b/apps/backend/internal/jira/mcp_client.go @@ -435,6 +435,14 @@ func (c *MCPClient) SearchTickets(ctx context.Context, jql, pageToken string, ma return parseMCPSearchResult(result, c.siteURL) } +// SearchTicketsForWatch uses the same MCP search tool because the remote MCP +// service owns the result field selection. Description may be empty when the +// MCP service does not include it by default; see +// docs/specs/integrations/system-design/jira-watcher-task-prompts.md. +func (c *MCPClient) SearchTicketsForWatch(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) { + return c.SearchTickets(ctx, jql, pageToken, maxResults) +} + // mcpIssue mirrors the subset of the Jira issue payload from the MCP tool. type mcpIssue struct { ID string `json:"id"` diff --git a/apps/backend/internal/jira/mock_client.go b/apps/backend/internal/jira/mock_client.go index 5736046907..faac533955 100644 --- a/apps/backend/internal/jira/mock_client.go +++ b/apps/backend/internal/jira/mock_client.go @@ -121,6 +121,12 @@ func (m *MockClient) SearchTickets(_ context.Context, jql, _ string, maxResults return &SearchResult{Tickets: out, MaxResults: maxResults, IsLast: true}, nil } +// SearchTicketsForWatch returns the same seeded issues as SearchTickets. The +// mock already includes every JiraTicket field, including descriptions. +func (m *MockClient) SearchTicketsForWatch(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) { + return m.SearchTickets(ctx, jql, pageToken, maxResults) +} + // filterByJQL is a stand-in for real JQL parsing. An empty query passes every // hit through; a `project in (...)` clause narrows to hits in those projects; // a `status in (...)` clause narrows to hits whose StatusName is listed; a diff --git a/apps/backend/internal/jira/service_issue_watch_test.go b/apps/backend/internal/jira/service_issue_watch_test.go index e6800e0fdb..e6a8912ce4 100644 --- a/apps/backend/internal/jira/service_issue_watch_test.go +++ b/apps/backend/internal/jira/service_issue_watch_test.go @@ -289,6 +289,49 @@ func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) { } } +func TestService_CheckIssueWatch_UsesWatcherSearch(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ + SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, InstanceType: InstanceTypeCloud, Secret: "tok", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + f.client.searchFn = func(_ string) (*SearchResult, error) { + return &SearchResult{ + Tickets: []JiraTicket{{Key: "PROJ-1"}}, + IsLast: true, + }, nil + } + f.client.watchSearchFn = func(_ string) (*SearchResult, error) { + return &SearchResult{ + Tickets: []JiraTicket{{Key: "PROJ-1", Description: "watcher description"}}, + IsLast: true, + }, nil + } + + w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + JQL: "project = PROJ", + }) + if err != nil { + t.Fatalf("create watch: %v", err) + } + + got, err := f.svc.CheckIssueWatch(ctx, w) + if err != nil { + t.Fatalf("check: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 unseen ticket, got %d", len(got)) + } + if got[0].Description != "watcher description" { + t.Errorf("description = %q, want watcher description", got[0].Description) + } +} + func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() @@ -298,7 +341,7 @@ func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) { }); err != nil { t.Fatalf("set config: %v", err) } - f.client.searchFn = func(_ string) (*SearchResult, error) { + f.client.watchSearchFn = func(_ string) (*SearchResult, error) { return nil, errors.New("upstream 500") } w, _ := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ diff --git a/apps/backend/internal/jira/service_test.go b/apps/backend/internal/jira/service_test.go index 4c0b1a9300..010828ad51 100644 --- a/apps/backend/internal/jira/service_test.go +++ b/apps/backend/internal/jira/service_test.go @@ -60,6 +60,7 @@ type fakeClient struct { listProjects func() ([]JiraProject, error) listStatuses func(projectKey string) ([]JiraStatus, error) searchFn func(jql string) (*SearchResult, error) + watchSearchFn func(jql string) (*SearchResult, error) transitionLog []string // "key:id" } @@ -104,6 +105,13 @@ func (c *fakeClient) SearchTickets(_ context.Context, jql, _ string, _ int) (*Se return &SearchResult{}, nil } +func (c *fakeClient) SearchTicketsForWatch(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) { + if c.watchSearchFn != nil { + return c.watchSearchFn(jql) + } + return c.SearchTickets(ctx, jql, pageToken, maxResults) +} + type svcFixture struct { svc *Service store *Store diff --git a/apps/backend/internal/jira/service_watch.go b/apps/backend/internal/jira/service_watch.go index c4acb6de9e..3261091f3b 100644 --- a/apps/backend/internal/jira/service_watch.go +++ b/apps/backend/internal/jira/service_watch.go @@ -239,7 +239,7 @@ func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*JiraTi // Single page is enough per tick: the next poll picks up anything that // overflowed maxResults. Pagination would let one chatty workspace starve // the others without any real freshness benefit. - res, err := client.SearchTickets(ctx, w.JQL, "", issueWatchSearchPageSize) + res, err := client.SearchTicketsForWatch(ctx, w.JQL, "", issueWatchSearchPageSize) if err != nil { return nil, err } diff --git a/apps/backend/internal/orchestrator/event_handlers_jira_test.go b/apps/backend/internal/orchestrator/event_handlers_jira_test.go index 0d45290558..06f9f72edb 100644 --- a/apps/backend/internal/orchestrator/event_handlers_jira_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_jira_test.go @@ -183,6 +183,7 @@ func TestInterpolateJiraPrompt(t *testing.T) { IssueType: "Bug", AssigneeName: "Alice", ProjectKey: "PROJ", + Description: "Steps to reproduce", } // Empty template falls back to a default that mentions key + URL. @@ -193,11 +194,19 @@ func TestInterpolateJiraPrompt(t *testing.T) { // All placeholders. got = interpolateJiraPrompt( - "{{issue.key}} | {{issue.summary}} | {{issue.status}} | {{issue.priority}} | {{issue.assignee}}", + "{{issue.key}} | {{issue.summary}} | {{issue.status}} | {{issue.priority}} | {{issue.assignee}} | {{issue.description}}", ticket, ) - want := "PROJ-7 | Login fails on mobile | In Progress | High | Alice" + want := "PROJ-7 | Login fails on mobile | In Progress | High | Alice | Steps to reproduce" if got != want { t.Errorf("got %q, want %q", got, want) } + + // A missing description resolves to an empty value rather than leaving the + // placeholder in the task prompt. + ticket.Description = "" + got = interpolateJiraPrompt("Description: {{issue.description}}", ticket) + if got != "Description: " { + t.Errorf("empty description = %q, want empty value", got) + } } diff --git a/docs/plans/fix-jira-watcher-description/plan.md b/docs/plans/fix-jira-watcher-description/plan.md new file mode 100644 index 0000000000..4cf60f3a94 --- /dev/null +++ b/docs/plans/fix-jira-watcher-description/plan.md @@ -0,0 +1,99 @@ +--- +created: 2026-08-28 +status: done +requirements: + - REQ-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001 +system_design: + - ../../specs/integrations/system-design/jira-watcher-task-prompts.md +legacy_specs: [] +--- + +# Implementation Plan: Fix Jira Watcher Description + +## Overview + +Jira issue #3130 reports an empty `{{issue.description}}` value in watcher-created tasks. The watcher uses a compact search request that omits the description field. + +One work order adds a watcher-specific search contract and regression coverage. The correction keeps interactive Jira searches compact and avoids per-issue requests. + +## Scope + +### In scope + +- Request the Jira description in watcher searches for Jira Cloud and Jira Server or Data Center. +- Keep the description through watcher filtering, event publication, prompt interpolation, and task creation. +- Keep the existing compact field list for interactive Jira searches and work-item references. +- Preserve the Atlassian MCP search behavior and the Jira mock behavior. + +### Out of scope + +- New placeholders or custom Jira fields. +- Jira rich-text style conversion beyond the current readable text conversion. +- Changes to JQL, pagination, deduplication, routing, or automatic start behavior. +- Frontend production changes or new user-facing text. + +## Confirmed root cause + +`CloudClient.SearchTickets` sends an explicit REST `fields` list. That list omits `description`, so Jira omits the value from each search result. + +`Service.CheckIssueWatch` uses that compact search result as the event payload. `interpolateJiraPrompt` receives an empty `JiraTicket.Description` and replaces the placeholder with an empty string. + +A temporary regression test used an HTTP test server that honored the requested field list. The test failed with `description = "", want Jira description`. + +## Technical approach + +### Provider client boundary + +- Add `SearchTicketsForWatch` to `internal/jira.Client` in `apps/backend/internal/jira/client.go`. +- Keep `SearchTickets` as the compact browse and work-item reference method. +- Reuse the Cloud and Server or Data Center search paths in `cloud_client.go` with an explicit field set. +- Include `description` only in the watcher field set. +- Delegate the MCP and mock watcher methods to their current search behavior. + +### Watcher flow + +- Change `Service.CheckIssueWatch` in `service_watch.go` to use `SearchTicketsForWatch`. +- Update test clients that implement `Client`. +- Do not add per-issue `GetTicket` calls. That method also requests transitions and causes request fan-out. + +### Regression coverage + +- Add a table-driven REST client test in `cloud_client_test.go` for Cloud and Server or Data Center. +- Record the requested REST field list and assert watcher searches request a description while compact searches do not. +- Add a service test in `service_issue_watch_test.go` that distinguishes compact search from watcher search. +- Extend `TestInterpolateJiraPrompt` in `event_handlers_jira_test.go` with the description placeholder. + +## Tests + +| Acceptance criterion | Evidence | +| --- | --- | +| `AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.1` | `TestInterpolateJiraPrompt` resolves the supported description placeholder before task creation. | +| `AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.2` | A new Cloud and Server or Data Center client test requests and converts watcher descriptions. | +| `AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.3` | Existing `TestCloudClient_GetTicket_ParsesADFDescription` plus the new watcher search test cover ADF and plain strings. | +| `AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.4` | `TestInterpolateJiraPrompt` covers an empty description without an unresolved token. | + +## E2E tests + +The service and real REST client test form the faithful end-to-end provider path for this regression. The in-memory Playwright Jira mock already supplies descriptions. + +A new Playwright test passes before the correction because that mock does not enforce Jira REST field selection. This package does not add that false-positive test. + +## Work orders + +- [x] [Task 01: Preserve Jira watcher descriptions](task-01-preserve-jira-watcher-descriptions.md) + +## Verification results + +Implemented the watcher-specific search contract and regression coverage. + +Verification passed: + +- `cd apps/backend && go test ./internal/jira ./internal/orchestrator -count=1` +- `python3 scripts/lint-spec-files.py --all` +- `git diff --check -- apps/backend docs/specs docs/plans` + +## Risks + +- A new `Client` method requires updates to every production and test implementation. +- The Atlassian MCP tool controls its own fields. Its watcher method must preserve the current parser and result contract. +- A shared REST field list can increase payload size for interactive searches. The implementation must keep separate field sets. diff --git a/docs/plans/fix-jira-watcher-description/task-01-preserve-jira-watcher-descriptions.md b/docs/plans/fix-jira-watcher-description/task-01-preserve-jira-watcher-descriptions.md new file mode 100644 index 0000000000..fb764678f9 --- /dev/null +++ b/docs/plans/fix-jira-watcher-description/task-01-preserve-jira-watcher-descriptions.md @@ -0,0 +1,102 @@ +--- +id: "01-preserve-jira-watcher-descriptions" +title: "Preserve Jira watcher descriptions" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001 +acceptance_criteria: + - AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.1 + - AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.2 + - AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.3 + - AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.4 +system_design: + - ../../specs/integrations/system-design/jira-watcher-task-prompts.md +--- + +# Task 01: Preserve Jira Watcher Descriptions + +## Summary + +Add a watcher-specific Jira search contract that requests descriptions in the batched REST response. Keep compact searches unchanged and preserve descriptions through prompt interpolation. + +## In scope + +- Add and implement `Client.SearchTicketsForWatch` for REST, MCP, mock, and test clients. +- Include `description` only in REST watcher field sets. +- Route `Service.CheckIssueWatch` through the watcher method. +- Add regression tests at the REST client, service, and interpolation boundaries. + +## Out of scope + +- Frontend production changes. +- Per-issue hydration through `GetTicket`. +- Changes to the current description text converter. +- Changes to watcher persistence, polling frequency, or dispatch order. + +## Acceptance + +- The new regression test fails before the correction because the watcher REST request omits `description`. +- Jira Cloud and Server or Data Center watcher searches return readable descriptions in one search request. +- Compact Jira searches keep their current field set, and watcher task prompts contain the resolved description. + +## Verification + +```bash +cd apps/backend +go test ./internal/jira ./internal/orchestrator -count=1 +cd ../.. +python3 scripts/lint-spec-files.py --all +git diff --check -- apps/backend docs/specs docs/plans +``` + +## Files likely touched + +- `apps/backend/internal/jira/client.go` +- `apps/backend/internal/jira/cloud_client.go` +- `apps/backend/internal/jira/cloud_client_test.go` +- `apps/backend/internal/jira/mcp_client.go` +- `apps/backend/internal/jira/mock_client.go` +- `apps/backend/internal/jira/service_watch.go` +- `apps/backend/internal/jira/service_issue_watch_test.go` +- `apps/backend/internal/jira/service_test.go` +- `apps/backend/internal/orchestrator/event_handlers_jira_test.go` +- `docs/plans/fix-jira-watcher-description/plan.md` +- `docs/plans/fix-jira-watcher-description/task-01-preserve-jira-watcher-descriptions.md` + +## Dependencies + +None. + +## Risks + +- A missing implementation of the new interface method causes a compile error. +- Shared search helpers can accidentally add descriptions to interactive search payloads. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001` +- `docs/specs/integrations/system-design/jira-watcher-task-prompts.md` +- GitHub issue #3130 and its two screenshots. +- Atlassian Jira Cloud and Jira Data Center search field contracts. + +## Results + +Implemented `Client.SearchTicketsForWatch` for REST, MCP, mock, and test +clients. Jira REST watcher searches request `description` for Cloud and Server +or Data Center while compact searches retain their existing field list. + +Verification passed: + +- RED: `TestService_CheckIssueWatch_UsesWatcherSearch` failed with an empty + description before the production change. +- `cd apps/backend && go test ./internal/jira ./internal/orchestrator -count=1` + (2,383 tests passed) +- `python3 scripts/lint-spec-files.py --all` +- `git diff --check -- apps/backend docs/specs docs/plans` diff --git a/docs/specs/integrations/README.md b/docs/specs/integrations/README.md index 68964286ef..7e2b50f30b 100644 --- a/docs/specs/integrations/README.md +++ b/docs/specs/integrations/README.md @@ -50,6 +50,7 @@ outcomes that expose those contracts. - [GitLab MR Status Chip](requirements/gitlab-mr-status-chip.md) - [GitLab MR Badge on the Sidebar and Tasks-List Rows](requirements/gitlab-mr-task-list-badges.md) - [GitLab Workflow Sync](requirements/gitlab-workflow-sync.md) +- [Jira Watcher Task Prompts](requirements/jira-watcher-task-prompts.md) - [Jira Ticket Status Filter](requirements/jira-status-filter.md) - [MCP Tool Argument Validation](requirements/mcp-tool-argument-validation.md) - [Pull request outcome attribution](requirements/pr-outcome-attribution.md) @@ -85,6 +86,7 @@ outcomes that expose those contracts. - [GitLab MR Badge on the Sidebar and Tasks-List Rows System Design Part 1](system-design/gitlab-mr-task-list-badges-01.md) - [GitLab MR Badge on the Sidebar and Tasks-List Rows System Design Part 2](system-design/gitlab-mr-task-list-badges-02.md) - [GitLab MR Badge on the Sidebar and Tasks-List Rows System Design Part 3](system-design/gitlab-mr-task-list-badges-03.md) +- [Jira Watcher Task Prompts](system-design/jira-watcher-task-prompts.md) ## Migration record diff --git a/docs/specs/integrations/requirements/jira-watcher-task-prompts.md b/docs/specs/integrations/requirements/jira-watcher-task-prompts.md new file mode 100644 index 0000000000..ef1c4da3b7 --- /dev/null +++ b/docs/specs/integrations/requirements/jira-watcher-task-prompts.md @@ -0,0 +1,34 @@ +--- +status: active +system: integrations +created: 2026-08-28 +owners: + - kandev +--- + +# Jira Watcher Task Prompt Requirements + +## Overview + +Jira issue watchers create Kandev tasks from matching Jira issues. The integration system owns the Jira data that supplies each configured task prompt. + +## Requirements + +### REQ-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001: Complete Jira issue context + +**Intent:** A watcher-created task must contain the Jira issue context that the user selects in the task prompt template. + +**User story:** As a Jira watcher user, I want Jira placeholders to contain issue data, so that each new task has complete work instructions. + +#### Acceptance criteria + +- **AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.1:** When a watcher finds a matching issue, Kandev shall resolve each supported `{{issue.*}}` placeholder before it creates the task. +- **AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.2:** When Jira returns a description, `{{issue.description}}` shall contain readable description text for each supported Jira connection type. +- **AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.3:** When Jira returns Atlassian Document Format, the description shall keep text order and paragraph separation. A plain string shall stay unchanged. +- **AC-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001.4:** When Jira returns no description, Kandev shall replace the placeholder with an empty value and continue task creation. + +## Out of scope + +- Custom Jira fields that do not have supported placeholders. +- Exact reproduction of Jira rich-text styles, attachments, comments, or embedded media. +- Changes to watcher query, deduplication, task routing, or automatic start behavior. diff --git a/docs/specs/integrations/system-design/jira-watcher-task-prompts.md b/docs/specs/integrations/system-design/jira-watcher-task-prompts.md new file mode 100644 index 0000000000..4b1a7b5ec7 --- /dev/null +++ b/docs/specs/integrations/system-design/jira-watcher-task-prompts.md @@ -0,0 +1,72 @@ +--- +status: current +system: integrations +requirements: + - REQ-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001 +--- + +# Jira Watcher Task Prompt System Design + +## Purpose and boundaries + +The Jira integration owns the issue data for watcher task prompts. The task system receives the completed prompt and does not fetch Jira data. + +This design keeps interactive ticket searches compact. Watcher searches request the description in the same Jira search request that returns each matching issue. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| `REQ-INTEGRATIONS-JIRA-WATCHER-TASK-PROMPTS-001` | [Provider client contract](#provider-client-contract), [Watcher flow](#watcher-flow), [Description conversion](#description-conversion) | + +## Components and responsibilities + +- `internal/jira.Client` separates compact ticket searches from watcher searches. +- `internal/jira.CloudClient` selects REST fields for Jira Cloud and Jira Server or Data Center. +- `internal/jira.MCPClient` maps the Atlassian MCP search result to the same `JiraTicket` model. +- `internal/jira.Service.CheckIssueWatch` requests watcher issue data and filters issues that the watcher already processed. +- `internal/orchestrator.interpolateJiraPrompt` replaces supported placeholders before task creation. + +## Provider client contract + +`Client.SearchTickets` remains the compact search for ticket browsing and work-item references. Its REST field list does not include `description`. + +`Client.SearchTicketsForWatch` is the watcher search. The REST implementations include `description` with the existing compact fields in one batched request. + +Both methods keep the current JQL and pagination behavior. The MCP implementation uses the same remote search tool because that tool owns its result field selection. + +The mock implementation returns its seeded search issues for both methods. This behavior keeps integration tests independent from a remote Jira service. + +## Watcher flow + +1. `CheckIssueWatch` calls `SearchTicketsForWatch` for one bounded result page. +2. The provider client returns normalized `JiraTicket` values, including `Description`. +3. `CheckIssueWatch` removes issues that exist in the watch deduplication set. +4. The Jira poller publishes each new issue through `NewJiraIssueEvent`. +5. The watcher dispatcher calls `interpolateJiraPrompt` and creates the Kandev task. + +The flow does not call `GetTicket` for each issue. This rule prevents extra transition requests and a request fan-out for broad JQL results. + +## Description conversion + +Jira Cloud REST can return `description` as Atlassian Document Format. Jira Server or Data Center can also return a plain string. + +`extractDescription` keeps plain strings unchanged. For Atlassian Document Format, it keeps text order, explicit breaks, and paragraph separation. + +The interpolation function replaces a missing description with an empty string. It does not keep an unresolved `{{issue.description}}` token. + +## Failure and recovery + +A search error stops the current watcher pass before event publication. The next eligible poll can try the query again. + +A missing or null description does not stop task creation. Other issue fields remain available to the configured prompt. + +## Performance boundary + +Watcher polling gets all required fields in one paginated search request. Interactive searches do not receive large descriptions that their rows do not show. + +## Test strategy + +- REST client tests make sure that watcher searches request and convert descriptions for Cloud and Server or Data Center. +- Service tests make sure that `CheckIssueWatch` uses the watcher search contract and keeps the description on each new issue. +- Orchestrator tests make sure that `{{issue.description}}` appears in the completed task prompt.