fix: preserve Jira watcher descriptions (#3130) (#3132)

This commit is contained in:
Carlos Florêncio
2026-08-29 07:47:01 +00:00
committed by GitHub
parent bc25df33ff
commit 58cc84d193
14 changed files with 545 additions and 10 deletions
+1
View File
@@ -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
+22 -6
View File
@@ -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 {
@@ -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")
+8
View File
@@ -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"`
@@ -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
@@ -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{
@@ -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
+1 -1
View File
@@ -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
}
@@ -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)
}
}
@@ -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.
@@ -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`
+2
View File
@@ -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
@@ -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.
@@ -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.