@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+102
@@ -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`
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user