54b98ed9eb
* feat(jira): add Jira integration for ticket browsing, import, and task linking Adds a workspace-scoped Atlassian Cloud integration: per-workspace settings (site URL, email, API token or session cookie), encrypted secret storage, and a REST client for tickets, projects, search, and transitions. A background poller probes credentials on a 90s cadence and the keepalive runs immediately on save so the UI's connected indicator updates without delay. The /jira page provides ticket browsing with filter pills, custom JQL, saved views (including JQL), and configurable task-launch presets that prefill TaskCreateDialog. JiraLinkButton on the task top bar links an existing task to a ticket by prepending its key to the title; the ticket popover surfaces details, transitions, and re-auth prompts on session expiry. Topbar shows separate icon buttons for GitHub and Jira when each is configured. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): address PR review feedback - Token-based pagination (pageToken/isLast) replaces broken offset/total pagination - Race-fix in service.clientFor: re-check cache after acquiring write lock - sync.Mutex guards poller started/cancel state against concurrent Start/Stop - Channel-based + atomic.Int32 test sync replaces time.Sleep polling in poller_test - Async auth probe on SetConfig with detached context.Background goroutine - Distinguish 4xx vs 5xx in httpSetConfig via ErrInvalidConfig sentinel - Propagate 4xx APIErrors from ListTransitions in GetTicket - Distinguish "not found" from real errors in jiraSecretAdapter.Exists - Log secret Exists errors instead of swallowing - walkADF: handle softBreak (was only hardBreak despite the doc-comment) - Consolidate JIRA_KEY_RE in jira-ticket-common; remove duplicates - ticket-row imports formatRelative/statusBadgeClass from common - isFilterState type guard validates SavedView.filters shape - task-presets readStorage preserves user's "cleared all" intent (returns []) - task-presets-section gates draft sync on `loaded` to avoid wiping in-progress edits - jira-api: caller `init` no longer overrides method/body via spread order - jira-settings: disable Test button when no saved/typed secret Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): record auth probe failure even when probe ctx expires - RecordAuthHealth now uses a fresh context.Background-derived ctx with a 5s timeout for the UpdateAuthHealth DB write, so a probe that consumes its full 15s deadline still persists the failure (greptile P1). - SetConfig goroutine drops its redundant outer timeout — RecordAuthHealth manages its own probe timeout, so context.Background is enough. - Add Service.SetProbeHook so tests can synchronize on probe completion; service_test waitForAuthProbe now blocks on the hook channel instead of time.Sleep polling. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): address coderabbit review feedback (round 3) - url.PathEscape ticketKey in GetTicket/ListTransitions/DoTransition paths to harden against malicious keys ('../../foo', 'KEY?override=x'). - factoryHit on the test fixture is now sync/atomic.Int32; the async probe goroutine spawned by SetConfig races the test thread under -race. - GetConfig logs Reveal failures (was silently dropped, leaving the UI to show "no expiry info" indistinguishably from a freshly-saved cookie). - DeleteConfig logs secret-delete errors instead of swallowing them. - clientFor distinguishes a secret-store failure from "not configured" so the UI doesn't render the configure-CTA for transient backend errors. - Unify ErrNotConfigured wire code: HTTP and WebSocket both return "JIRA_NOT_CONFIGURED" via a shared constant. - JiraLinkButton strips a leading "PROJ-123: " before re-prefixing so re-linking a task replaces the prefix instead of stacking it. - jira-settings clears testResult on save/delete so the alert reflects only the currently-saved credentials. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(jira): cover Reveal error path in clientFor Reveal-error case is distinct from "no config row" — the former wraps the underlying error, the latter returns ErrNotConfigured. Without this test the changed contract has no regression coverage. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): route secret adapter Set through Exists Treating every Get error as "not found" turned a transient DB error on an existing row into a constraint-violation Create that masked the real cause. Reuse Exists so the "secret not found:" prefix is the only path that falls through to Create. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(jira): cover jiraSecretAdapter.Set branching Routing Set through Exists changed the contract: a transient DB error in Get now propagates instead of cascading into a constraint-violation Create. Add three tests so a future refactor cannot regress this without CI catching it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): split Reveal errors and guard Save for new configs - resolveCredentials now mirrors the clientFor split: a Reveal error surfaces as `read jira secret: %w` with a warn log instead of collapsing into "no token stored — paste one to test". - disableSave gains the same missing-secret guard as disableTest so a brand-new config can no longer POST an empty secret and leave the workspace stuck with hasSecret=false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
254 lines
7.9 KiB
Go
254 lines
7.9 KiB
Go
package jira
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
s := httptest.NewServer(handler)
|
|
t.Cleanup(s.Close)
|
|
return s
|
|
}
|
|
|
|
func clientTo(ts *httptest.Server, method, secret string) *CloudClient {
|
|
cfg := &JiraConfig{
|
|
SiteURL: ts.URL,
|
|
Email: "user@example.com",
|
|
AuthMethod: method,
|
|
}
|
|
return NewCloudClient(cfg, secret)
|
|
}
|
|
|
|
func TestCloudClient_AuthHeaders_APIToken(t *testing.T) {
|
|
var gotAuth string
|
|
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"accountId":"a","displayName":"d","emailAddress":"e"}`))
|
|
})
|
|
c := clientTo(ts, AuthMethodAPIToken, "secrettoken")
|
|
|
|
res, err := c.TestAuth(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("test auth: %v", err)
|
|
}
|
|
if !res.OK {
|
|
t.Fatalf("expected OK=true, got %+v", res)
|
|
}
|
|
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("user@example.com:secrettoken"))
|
|
if gotAuth != want {
|
|
t.Errorf("auth header = %q, want %q", gotAuth, want)
|
|
}
|
|
}
|
|
|
|
func TestCloudClient_AuthHeaders_SessionCookie_BareJWT(t *testing.T) {
|
|
var gotCookie string
|
|
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotCookie = r.Header.Get("Cookie")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"accountId":"a"}`))
|
|
})
|
|
// New flow: user pastes just the value of cloud.session.token /
|
|
// tenant.session.token from DevTools → Application → Cookies. The client
|
|
// wraps it under both names so a single paste works for password accounts
|
|
// and SSO tenants alike.
|
|
jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.sig"
|
|
c := clientTo(ts, AuthMethodSessionCookie, jwt)
|
|
if _, err := c.TestAuth(context.Background()); err != nil {
|
|
t.Fatalf("test auth: %v", err)
|
|
}
|
|
want := "cloud.session.token=" + jwt + "; tenant.session.token=" + jwt
|
|
if gotCookie != want {
|
|
t.Errorf("cookie header = %q, want %q", gotCookie, want)
|
|
}
|
|
}
|
|
|
|
func TestCloudClient_TestAuth_BadCreds_ReportsError(t *testing.T) {
|
|
ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"errorMessages":["bad creds"]}`))
|
|
})
|
|
c := clientTo(ts, AuthMethodAPIToken, "bad")
|
|
res, err := c.TestAuth(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("test auth should not error on 401, got %v", err)
|
|
}
|
|
if res.OK {
|
|
t.Fatalf("expected OK=false, got %+v", res)
|
|
}
|
|
if !strings.Contains(res.Error, "401") {
|
|
t.Errorf("expected 401 in error, got %q", res.Error)
|
|
}
|
|
}
|
|
|
|
func TestCloudClient_GetTicket_ParsesADFDescription(t *testing.T) {
|
|
issueBody := `{
|
|
"key": "PROJ-42",
|
|
"fields": {
|
|
"summary": "Fix the thing",
|
|
"description": {
|
|
"type": "doc",
|
|
"content": [
|
|
{"type":"paragraph","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]},
|
|
{"type":"paragraph","content":[{"type":"text","text":"line two"}]}
|
|
]
|
|
},
|
|
"status": {"id":"3","name":"In Progress"},
|
|
"project": {"key":"PROJ"},
|
|
"issuetype": {"name":"Bug"}
|
|
}
|
|
}`
|
|
transitionsBody := `{"transitions":[{"id":"11","name":"Start","to":{"id":"3","name":"In Progress"}}]}`
|
|
|
|
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/PROJ-42/transitions"):
|
|
_, _ = w.Write([]byte(transitionsBody))
|
|
case strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/PROJ-42"):
|
|
_, _ = w.Write([]byte(issueBody))
|
|
default:
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
|
|
c := clientTo(ts, AuthMethodAPIToken, "tok")
|
|
ticket, err := c.GetTicket(context.Background(), "PROJ-42")
|
|
if err != nil {
|
|
t.Fatalf("get ticket: %v", err)
|
|
}
|
|
if ticket.Summary != "Fix the thing" {
|
|
t.Errorf("summary: got %q", ticket.Summary)
|
|
}
|
|
if !strings.Contains(ticket.Description, "Hello world") {
|
|
t.Errorf("description missing first line: %q", ticket.Description)
|
|
}
|
|
if !strings.Contains(ticket.Description, "line two") {
|
|
t.Errorf("description missing second paragraph: %q", ticket.Description)
|
|
}
|
|
if ticket.StatusName != "In Progress" {
|
|
t.Errorf("status name: got %q", ticket.StatusName)
|
|
}
|
|
if len(ticket.Transitions) != 1 || ticket.Transitions[0].Name != "Start" {
|
|
t.Errorf("transitions: %+v", ticket.Transitions)
|
|
}
|
|
if ticket.URL != ts.URL+"/browse/PROJ-42" {
|
|
t.Errorf("url: got %q", ticket.URL)
|
|
}
|
|
}
|
|
|
|
func TestCloudClient_GetTicket_PlainStringDescription(t *testing.T) {
|
|
body := `{"key":"X-1","fields":{"summary":"s","description":"plain","status":{},"project":{},"issuetype":{}}}`
|
|
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/X-1/transitions") {
|
|
_, _ = w.Write([]byte(`{"transitions":[]}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(body))
|
|
})
|
|
c := clientTo(ts, AuthMethodAPIToken, "t")
|
|
ticket, err := c.GetTicket(context.Background(), "X-1")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if ticket.Description != "plain" {
|
|
t.Errorf("description: got %q", ticket.Description)
|
|
}
|
|
}
|
|
|
|
func TestCloudClient_GetTicket_NonOK_ReturnsAPIError(t *testing.T) {
|
|
ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"errorMessages":["not found"]}`))
|
|
})
|
|
c := clientTo(ts, AuthMethodAPIToken, "t")
|
|
_, err := c.GetTicket(context.Background(), "NONE-1")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
|
}
|
|
if apiErr.StatusCode != 404 {
|
|
t.Errorf("status: got %d", apiErr.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestCloudClient_DoTransition_PostsBody(t *testing.T) {
|
|
var gotPath, gotBody, gotMethod string
|
|
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotPath = r.URL.Path
|
|
buf := make([]byte, r.ContentLength)
|
|
_, _ = r.Body.Read(buf)
|
|
gotBody = string(buf)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
c := clientTo(ts, AuthMethodAPIToken, "t")
|
|
if err := c.DoTransition(context.Background(), "PROJ-1", "21"); err != nil {
|
|
t.Fatalf("transition: %v", err)
|
|
}
|
|
if gotMethod != http.MethodPost {
|
|
t.Errorf("method: got %q", gotMethod)
|
|
}
|
|
if gotPath != "/rest/api/3/issue/PROJ-1/transitions" {
|
|
t.Errorf("path: got %q", gotPath)
|
|
}
|
|
if !strings.Contains(gotBody, `"id":"21"`) {
|
|
t.Errorf("body missing transition id: %q", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestCloudClient_ListProjects(t *testing.T) {
|
|
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.HasPrefix(r.URL.Path, "/rest/api/3/project/search") {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"values":[{"id":"1","key":"A","name":"Alpha"},{"id":"2","key":"B","name":"Beta"}]}`))
|
|
})
|
|
c := clientTo(ts, AuthMethodAPIToken, "t")
|
|
projects, err := c.ListProjects(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(projects) != 2 || projects[0].Key != "A" || projects[1].Name != "Beta" {
|
|
t.Errorf("unexpected projects: %+v", projects)
|
|
}
|
|
}
|
|
|
|
func TestCloudClient_SiteURLTrailingSlash_Stripped(t *testing.T) {
|
|
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
// If trailing slash wasn't stripped, we'd see "//rest/..."
|
|
if strings.HasPrefix(r.URL.Path, "//") {
|
|
t.Errorf("double slash in path: %s", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{}`))
|
|
})
|
|
cfg := &JiraConfig{SiteURL: ts.URL + "/", Email: "e", AuthMethod: AuthMethodAPIToken}
|
|
c := NewCloudClient(cfg, "t")
|
|
if _, err := c.TestAuth(context.Background()); err != nil {
|
|
t.Fatalf("test: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExtractDescription_Nil(t *testing.T) {
|
|
if got := extractDescription(nil); got != "" {
|
|
t.Errorf("nil → %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractDescription_UnknownShape(t *testing.T) {
|
|
if got := extractDescription(42); got != "" {
|
|
t.Errorf("int → %q", got)
|
|
}
|
|
}
|