diff --git a/apps/backend/cmd/kandev/adapters_test.go b/apps/backend/cmd/kandev/adapters_test.go index 26a7100dd9..0a57fccfe1 100644 --- a/apps/backend/cmd/kandev/adapters_test.go +++ b/apps/backend/cmd/kandev/adapters_test.go @@ -2,10 +2,13 @@ package main import ( "context" + "errors" + "fmt" "sync" "testing" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" taskservice "github.com/kandev/kandev/internal/task/service" ) @@ -108,3 +111,84 @@ func TestMessageCreatorAdapter_StructFields(t *testing.T) { t.Error("expected nil svc") } } + +// fakeJiraSecretStore is a minimal SecretStore for exercising +// jiraSecretAdapter.Set's branching. Get is the only method whose return +// value drives behaviour; the rest record whether they were called so +// tests can assert that a Get failure does not cascade into Create. +type fakeJiraSecretStore struct { + getErr error + created bool + updated bool +} + +func (f *fakeJiraSecretStore) Create(_ context.Context, _ *secrets.SecretWithValue) error { + f.created = true + return nil +} + +func (f *fakeJiraSecretStore) Get(_ context.Context, _ string) (*secrets.Secret, error) { + if f.getErr != nil { + return nil, f.getErr + } + return &secrets.Secret{}, nil +} + +func (f *fakeJiraSecretStore) Reveal(_ context.Context, _ string) (string, error) { + return "", nil +} + +func (f *fakeJiraSecretStore) Update(_ context.Context, _ string, _ *secrets.UpdateSecretRequest) error { + f.updated = true + return nil +} + +func (f *fakeJiraSecretStore) Delete(_ context.Context, _ string) error { return nil } +func (f *fakeJiraSecretStore) List(_ context.Context) ([]*secrets.SecretListItem, error) { + return nil, nil +} +func (f *fakeJiraSecretStore) Close() error { return nil } + +func TestJiraSecretAdapter_Set_PropagatesRealDBError(t *testing.T) { + dbErr := errors.New("connection timeout") + store := &fakeJiraSecretStore{getErr: dbErr} + adapter := &jiraSecretAdapter{store: store} + + err := adapter.Set(context.Background(), "id", "name", "value") + if !errors.Is(err, dbErr) { + t.Fatalf("expected wrapped DB error, got %v", err) + } + if store.created || store.updated { + t.Fatal("Create/Update must not run when Get returns a real error") + } +} + +func TestJiraSecretAdapter_Set_CreatesWhenNotFound(t *testing.T) { + store := &fakeJiraSecretStore{getErr: fmt.Errorf("secret not found: id")} + adapter := &jiraSecretAdapter{store: store} + + if err := adapter.Set(context.Background(), "id", "name", "value"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !store.created { + t.Fatal("expected Create when secret is missing") + } + if store.updated { + t.Fatal("Update must not run when secret is missing") + } +} + +func TestJiraSecretAdapter_Set_UpdatesWhenExists(t *testing.T) { + store := &fakeJiraSecretStore{getErr: nil} + adapter := &jiraSecretAdapter{store: store} + + if err := adapter.Set(context.Background(), "id", "name", "value"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !store.updated { + t.Fatal("expected Update when secret exists") + } + if store.created { + t.Fatal("Create must not run when secret exists") + } +} diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 3950caba7c..33701c837d 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -33,6 +33,7 @@ import ( gateways "github.com/kandev/kandev/internal/gateway/websocket" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/health" + "github.com/kandev/kandev/internal/jira" mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" @@ -469,6 +470,11 @@ func registerSecondaryRoutes( p.log.Debug("Registered GitHub handlers (HTTP + WebSocket)") } + if p.services.Jira != nil { + jira.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Jira, p.log) + p.log.Debug("Registered JIRA handlers (HTTP + WebSocket)") + } + docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index f082ec9337..cbb6b8a10d 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -29,6 +29,9 @@ import ( // GitHub integration githubpkg "github.com/kandev/kandev/internal/github" + // JIRA integration + jirapkg "github.com/kandev/kandev/internal/jira" + // Agent infrastructure "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/lifecycle" @@ -333,6 +336,15 @@ func startAgentInfrastructure( log.Info("GitHub poller started") } + // Start JIRA auth-health poller. Probes stored credentials for every + // configured workspace on a fixed cadence so the UI can show a real + // connected/disconnected status without doing its own polling. + if services.Jira != nil { + jiraPoller := jirapkg.NewPoller(services.Jira, log) + jiraPoller.Start(ctx) + addCleanup(func() error { jiraPoller.Stop(); return nil }) + } + return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, addCleanup, runCleanups) } diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 5c23e4ffee..2c14cb19dd 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -2,6 +2,7 @@ package main import ( "context" + "strings" "go.uber.org/zap" @@ -14,6 +15,7 @@ import ( editorservice "github.com/kandev/kandev/internal/editors/service" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/jira" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/secrets" taskmodels "github.com/kandev/kandev/internal/task/models" @@ -92,6 +94,13 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories githubSvc.SetSecretManager(secretsAdapter) } + // Initialize JIRA service + jiraSecrets := &jiraSecretAdapter{store: repos.Secrets} + jiraSvc, _, jiraErr := jira.Provide(dbPool.Writer(), dbPool.Reader(), jiraSecrets, log) + if jiraErr != nil { + log.Warn("JIRA service initialization failed (non-fatal)", zap.Error(jiraErr)) + } + return &Services{ Task: taskSvc, User: userSvc, @@ -100,6 +109,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Utility: utilitySvc, Workflow: workflowSvc, GitHub: githubSvc, + Jira: jiraSvc, // Notification service is initialized after gateway is available. Notification: nil, }, agentSettingsController, nil @@ -213,6 +223,57 @@ func (a *githubSecretAdapter) Delete(ctx context.Context, id string) error { return a.store.Delete(ctx, id) } +// jiraSecretAdapter adapts secrets.SecretStore to jira.SecretStore. JIRA stores +// tokens keyed by "jira:{workspaceID}:token"; this adapter translates the +// upsert-style API the JIRA service expects onto the Create/Update methods the +// secret store exposes. +type jiraSecretAdapter struct { + store secrets.SecretStore +} + +func (a *jiraSecretAdapter) Reveal(ctx context.Context, id string) (string, error) { + return a.store.Reveal(ctx, id) +} + +func (a *jiraSecretAdapter) Set(ctx context.Context, id, name, value string) error { + // Try update first; on "not found", fall through to create. The secrets + // layer only exposes error strings (not typed errors), so we detect the + // missing row via Exists which inspects the "secret not found:" prefix. + // Treating every Get error as "not found" would turn a transient DB error + // on an existing row into a constraint-violation Create that masks the + // real cause. + exists, err := a.Exists(ctx, id) + if err != nil { + return err + } + if exists { + return a.store.Update(ctx, id, &secrets.UpdateSecretRequest{Value: &value}) + } + return a.store.Create(ctx, &secrets.SecretWithValue{ + Secret: secrets.Secret{ID: id, Name: name}, + Value: value, + }) +} + +func (a *jiraSecretAdapter) Delete(ctx context.Context, id string) error { + return a.store.Delete(ctx, id) +} + +func (a *jiraSecretAdapter) Exists(ctx context.Context, id string) (bool, error) { + _, err := a.store.Get(ctx, id) + if err != nil { + // The secrets layer reports "not found" via a fmt.Errorf string with + // the "secret not found:" prefix; treat that as the absence case and + // surface anything else (DB outage, decoding failure) so the caller + // can log it instead of silently reporting "not configured". + if strings.HasPrefix(err.Error(), "secret not found:") { + return false, nil + } + return false, err + } + return true, nil +} + // workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface. type workflowProviderAdapter struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 229972f3da..42fff0c4c0 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -6,6 +6,7 @@ import ( editorservice "github.com/kandev/kandev/internal/editors/service" editorstore "github.com/kandev/kandev/internal/editors/store" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/jira" notificationservice "github.com/kandev/kandev/internal/notifications/service" notificationstore "github.com/kandev/kandev/internal/notifications/store" promptservice "github.com/kandev/kandev/internal/prompts/service" @@ -43,4 +44,5 @@ type Services struct { Utility *utilityservice.Service Workflow *workflowservice.Service GitHub *github.Service + Jira *jira.Service } diff --git a/apps/backend/internal/jira/client.go b/apps/backend/internal/jira/client.go new file mode 100644 index 0000000000..0e4ccc556d --- /dev/null +++ b/apps/backend/internal/jira/client.go @@ -0,0 +1,33 @@ +package jira + +import ( + "context" + "errors" + "fmt" +) + +// ErrNotConfigured is returned when a Jira operation is attempted without a +// workspace configuration. +var ErrNotConfigured = errors.New("jira: workspace not configured") + +// Client is the minimal interface service needs from a Jira backend. The real +// implementation is CloudClient; tests can substitute a fake. +type Client interface { + TestAuth(ctx context.Context) (*TestConnectionResult, error) + GetTicket(ctx context.Context, ticketKey string) (*JiraTicket, error) + ListTransitions(ctx context.Context, ticketKey string) ([]JiraTransition, error) + DoTransition(ctx context.Context, ticketKey, transitionID string) error + ListProjects(ctx context.Context) ([]JiraProject, error) + SearchTickets(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) +} + +// APIError captures an upstream non-2xx response so handlers can surface a +// meaningful status to the UI. +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("jira api: status %d: %s", e.StatusCode, e.Message) +} diff --git a/apps/backend/internal/jira/cloud_client.go b/apps/backend/internal/jira/cloud_client.go new file mode 100644 index 0000000000..f568749447 --- /dev/null +++ b/apps/backend/internal/jira/cloud_client.go @@ -0,0 +1,443 @@ +package jira + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// CloudClient is an Atlassian Cloud REST v3 client. It supports two auth modes: +// +// - api_token: Basic auth with {email}:{token} (the standard, recommended path). +// - session_cookie: the secret is the JWT value of `cloud.session.token` +// (password accounts) or `tenant.session.token` (SSO), copied from DevTools +// → Application → Cookies. The client wraps it under both cookie names so +// a single paste works for both account types. +// +// The client holds no state beyond credentials so it can be recreated cheaply +// per workspace if config changes. +type CloudClient struct { + http *http.Client + siteURL string + email string + secret string + authMethod string + maxBodySize int64 +} + +// NewCloudClient builds a client from a JiraConfig + secret. siteURL is +// normalized: trailing slash stripped, https:// prepended when the user saved +// only a hostname (legacy rows; new rows are normalized on save). +func NewCloudClient(cfg *JiraConfig, secret string) *CloudClient { + site := strings.TrimRight(cfg.SiteURL, "/") + if site != "" && !strings.Contains(site, "://") { + site = "https://" + site + } + return &CloudClient{ + http: &http.Client{ + Timeout: 30 * time.Second, + // Don't follow redirects: Jira REST endpoints shouldn't redirect for + // authenticated calls. Atlassian redirects unauthenticated or + // step-up-required requests to a login HTML page (with a 200 status), + // which masks the real auth failure and breaks JSON decoding. By + // returning the last response as-is, we preserve the 3xx status and + // the informative body ("Step-up authentication is required..."). + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + siteURL: site, + email: cfg.Email, + secret: secret, + authMethod: cfg.AuthMethod, + maxBodySize: 4 << 20, // 4 MB — Jira payloads are small by design. + } +} + +// authorize applies the client's auth strategy to a request. +const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)" + +func (c *CloudClient) authorize(req *http.Request) { + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + switch c.authMethod { + case AuthMethodAPIToken: + basic := base64.StdEncoding.EncodeToString([]byte(c.email + ":" + c.secret)) + req.Header.Set("Authorization", "Basic "+basic) + case AuthMethodSessionCookie: + req.Header.Set("Cookie", buildSessionCookieHeader(c.secret)) + } +} + +// buildSessionCookieHeader wraps a bare session-token JWT under both +// known Atlassian cookie names. A single paste works for password accounts +// (`cloud.session.token`) and SSO tenants (`tenant.session.token`) without +// asking the user to know which one they have. +func buildSessionCookieHeader(secret string) string { + return "cloud.session.token=" + secret + "; tenant.session.token=" + secret +} + +// do executes a request and decodes a 2xx JSON body into out (may be nil). +// Non-2xx responses are returned as *APIError so callers can switch on status. +func (c *CloudClient) do(ctx context.Context, method, path string, body interface{}, out interface{}) error { + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal body: %w", err) + } + reqBody = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.siteURL+path, reqBody) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + c.authorize(req) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBodySize)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{StatusCode: resp.StatusCode, Message: summarizeBody(resp, raw)} + } + // Guardrail: some misconfigured Atlassian flows return a 200 HTML login + // page instead of JSON. If we accidentally get HTML, surface an auth error + // rather than letting json.Unmarshal fail with "invalid character '<'". + if isHTMLResponse(resp, raw) { + return &APIError{StatusCode: resp.StatusCode, Message: "Atlassian returned an HTML page — the session likely requires step-up auth or has expired. Refresh your Jira tab and copy the cookie again."} + } + if out == nil || len(raw) == 0 { + return nil + } + return json.Unmarshal(raw, out) +} + +// isHTMLResponse checks whether a response body is HTML rather than JSON, so +// we can convert Atlassian's implicit-login-page responses into a clear +// auth error message. +func isHTMLResponse(resp *http.Response, raw []byte) bool { + ct := resp.Header.Get("Content-Type") + if strings.Contains(strings.ToLower(ct), "text/html") { + return true + } + trimmed := bytes.TrimLeft(raw, " \t\r\n") + return bytes.HasPrefix(trimmed, []byte("<")) +} + +// summarizeBody returns a short, useful error message from a non-2xx response. +// For HTML bodies it skips the page content in favor of a step-up hint; for +// plain text or JSON it returns the body verbatim (capped, so we don't spam +// logs with multi-KB pages). +func summarizeBody(resp *http.Response, raw []byte) string { + if isHTMLResponse(resp, raw) { + return "Atlassian returned an HTML page (status " + strconv.Itoa(resp.StatusCode) + + "). This usually means step-up authentication is required; sign in again in your Jira tab and re-copy the cookie." + } + const maxMsg = 500 + if len(raw) > maxMsg { + return string(raw[:maxMsg]) + "…" + } + return string(raw) +} + +// TestAuth hits /rest/api/3/myself which is the cheapest authenticated +// endpoint; a 200 proves credentials work and identifies the user. +func (c *CloudClient) TestAuth(ctx context.Context) (*TestConnectionResult, error) { + var body struct { + AccountID string `json:"accountId"` + DisplayName string `json:"displayName"` + EmailAddress string `json:"emailAddress"` + } + if err := c.do(ctx, http.MethodGet, "/rest/api/3/myself", nil, &body); err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) { + return &TestConnectionResult{OK: false, Error: apiErr.Error()}, nil + } + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return &TestConnectionResult{ + OK: true, + AccountID: body.AccountID, + DisplayName: body.DisplayName, + Email: body.EmailAddress, + }, nil +} + +// issueResponse mirrors the subset of the Atlassian issue payload we consume. +type issueResponse struct { + Key string `json:"key"` + Fields struct { + Summary string `json:"summary"` + Description interface{} `json:"description"` // ADF or string depending on API version + Updated string `json:"updated"` + Status struct { + ID string `json:"id"` + Name string `json:"name"` + StatusCategory struct { + Key string `json:"key"` // "new" | "indeterminate" | "done" + } `json:"statusCategory"` + } `json:"status"` + Project struct { + Key string `json:"key"` + } `json:"project"` + IssueType struct { + Name string `json:"name"` + IconURL string `json:"iconUrl"` + } `json:"issuetype"` + Priority struct { + Name string `json:"name"` + IconURL string `json:"iconUrl"` + } `json:"priority"` + Assignee *jiraUser `json:"assignee"` + Reporter *jiraUser `json:"reporter"` + } `json:"fields"` +} + +type jiraUser struct { + DisplayName string `json:"displayName"` + AvatarURLs struct { + Size24 string `json:"24x24"` + Size32 string `json:"32x32"` + } `json:"avatarUrls"` +} + +func (u *jiraUser) avatar() string { + if u == nil { + return "" + } + if u.AvatarURLs.Size24 != "" { + return u.AvatarURLs.Size24 + } + return u.AvatarURLs.Size32 +} + +func (u *jiraUser) name() string { + if u == nil { + return "" + } + return u.DisplayName +} + +type transitionsResponse struct { + Transitions []struct { + ID string `json:"id"` + Name string `json:"name"` + To struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"to"` + } `json:"transitions"` +} + +// GetTicket fetches the ticket + available transitions in two calls. We ask +// Jira for the ADF-rendered description so the UI gets plain-text rather than +// an opaque document tree. A 4xx on the transitions call is bubbled up so the +// UI can surface auth/permission failures rather than silently rendering a +// ticket with an empty transitions menu. +func (c *CloudClient) GetTicket(ctx context.Context, ticketKey string) (*JiraTicket, error) { + var issue issueResponse + path := "/rest/api/3/issue/" + url.PathEscape(ticketKey) + "?expand=renderedFields" + if err := c.do(ctx, http.MethodGet, path, nil, &issue); err != nil { + return nil, err + } + t := issueToTicket(&issue, c.siteURL) + transitions, terr := c.ListTransitions(ctx, ticketKey) + if terr != nil { + var apiErr *APIError + if errors.As(terr, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { + return nil, terr + } + // Network blips or 5xx: keep the ticket and let the UI render without + // transitions. The user can still read the ticket and refresh. + } else { + t.Transitions = transitions + } + return &t, nil +} + +// ListTransitions returns the transitions currently available for ticketKey. +func (c *CloudClient) ListTransitions(ctx context.Context, ticketKey string) ([]JiraTransition, error) { + var resp transitionsResponse + path := "/rest/api/3/issue/" + url.PathEscape(ticketKey) + "/transitions" + if err := c.do(ctx, http.MethodGet, path, nil, &resp); err != nil { + return nil, err + } + out := make([]JiraTransition, 0, len(resp.Transitions)) + for _, t := range resp.Transitions { + out = append(out, JiraTransition{ + ID: t.ID, + Name: t.Name, + ToStatusID: t.To.ID, + ToStatusName: t.To.Name, + }) + } + return out, nil +} + +// DoTransition asks Jira to apply a specific transition by ID. The Jira API +// returns 204 on success. +func (c *CloudClient) DoTransition(ctx context.Context, ticketKey, transitionID string) error { + body := map[string]interface{}{ + "transition": map[string]string{"id": transitionID}, + } + path := "/rest/api/3/issue/" + url.PathEscape(ticketKey) + "/transitions" + return c.do(ctx, http.MethodPost, path, body, nil) +} + +// ListProjects returns up to 200 projects (the Jira max per page for this +// endpoint). Fine for the settings dropdown; pagination can be added later if +// it ever becomes a problem. +func (c *CloudClient) ListProjects(ctx context.Context) ([]JiraProject, error) { + var body struct { + Values []struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + } `json:"values"` + } + if err := c.do(ctx, http.MethodGet, "/rest/api/3/project/search?maxResults=200", nil, &body); err != nil { + return nil, err + } + out := make([]JiraProject, 0, len(body.Values)) + for _, p := range body.Values { + out = append(out, JiraProject{ID: p.ID, Key: p.Key, Name: p.Name}) + } + return out, nil +} + +// searchResponse mirrors the subset of /rest/api/3/search/jql we consume. The +// new endpoint is token-paginated: there's no total count and pagination uses +// nextPageToken rather than startAt. Transitions are intentionally omitted from +// search results (fetched lazily when the user opens a ticket). +type searchResponse struct { + Issues []issueResponse `json:"issues"` + NextPageToken string `json:"nextPageToken"` + IsLast bool `json:"isLast"` +} + +// SearchTickets runs a JQL search and returns a page of tickets. Uses the +// /rest/api/3/search/jql endpoint (the legacy /search was removed by Atlassian +// in 2025). 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) { + if maxResults <= 0 { + maxResults = 25 + } + if maxResults > 100 { + maxResults = 100 + } + body := map[string]interface{}{ + "jql": jql, + "maxResults": maxResults, + "fields": []string{ + "summary", "status", "project", "issuetype", + "priority", "assignee", "reporter", "updated", + }, + } + if pageToken != "" { + body["nextPageToken"] = pageToken + } + var resp searchResponse + if err := c.do(ctx, http.MethodPost, "/rest/api/3/search/jql", body, &resp); err != nil { + return nil, err + } + out := &SearchResult{ + MaxResults: maxResults, + IsLast: resp.IsLast, + NextPageToken: resp.NextPageToken, + Tickets: make([]JiraTicket, 0, len(resp.Issues)), + } + for i := range resp.Issues { + out.Tickets = append(out.Tickets, issueToTicket(&resp.Issues[i], c.siteURL)) + } + return out, nil +} + +// issueToTicket converts the API response shape to our public JiraTicket. +// Factored out so GetTicket and SearchTickets stay consistent. +func issueToTicket(issue *issueResponse, siteURL string) JiraTicket { + return JiraTicket{ + Key: issue.Key, + Summary: issue.Fields.Summary, + StatusID: issue.Fields.Status.ID, + StatusName: issue.Fields.Status.Name, + StatusCategory: issue.Fields.Status.StatusCategory.Key, + ProjectKey: issue.Fields.Project.Key, + IssueType: issue.Fields.IssueType.Name, + IssueTypeIcon: issue.Fields.IssueType.IconURL, + Priority: issue.Fields.Priority.Name, + PriorityIcon: issue.Fields.Priority.IconURL, + AssigneeName: issue.Fields.Assignee.name(), + AssigneeAvatar: issue.Fields.Assignee.avatar(), + ReporterName: issue.Fields.Reporter.name(), + ReporterAvatar: issue.Fields.Reporter.avatar(), + Updated: issue.Fields.Updated, + URL: siteURL + "/browse/" + issue.Key, + Description: extractDescription(issue.Fields.Description), + } +} + +// extractDescription handles the three shapes Jira's `description` field may +// return: nil (empty), a plain string (older APIs / some integrations), or an +// Atlassian Document Format node tree (API v3). For ADF we walk the tree +// pulling out text nodes so the UI gets a readable markdown-ish version. +func extractDescription(raw interface{}) string { + if raw == nil { + return "" + } + if s, ok := raw.(string); ok { + return s + } + m, ok := raw.(map[string]interface{}) + if !ok { + return "" + } + var b strings.Builder + walkADF(m, &b) + return strings.TrimSpace(b.String()) +} + +// walkADF is a minimal Atlassian Document Format walker. It recognizes text +// nodes, hard/soft breaks, and paragraphs; other node types (code blocks, +// tables, mentions) collapse to their text content. Good enough for task +// descriptions — we don't try to preserve rich formatting. +func walkADF(node map[string]interface{}, b *strings.Builder) { + switch node["type"] { + case "text": + if s, ok := node["text"].(string); ok { + b.WriteString(s) + } + case "hardBreak", "softBreak": + b.WriteString("\n") + } + content, ok := node["content"].([]interface{}) + if !ok { + return + } + for i, c := range content { + if child, ok := c.(map[string]interface{}); ok { + walkADF(child, b) + } + // Insert paragraph breaks between top-level siblings. + if t, _ := node["type"].(string); t == "doc" && i < len(content)-1 { + b.WriteString("\n\n") + } + } +} diff --git a/apps/backend/internal/jira/cloud_client_test.go b/apps/backend/internal/jira/cloud_client_test.go new file mode 100644 index 0000000000..241c5c679c --- /dev/null +++ b/apps/backend/internal/jira/cloud_client_test.go @@ -0,0 +1,253 @@ +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) + } +} diff --git a/apps/backend/internal/jira/handlers.go b/apps/backend/internal/jira/handlers.go new file mode 100644 index 0000000000..da43f1f719 --- /dev/null +++ b/apps/backend/internal/jira/handlers.go @@ -0,0 +1,354 @@ +package jira + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// RegisterRoutes wires the Jira HTTP and WebSocket handlers. +func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + ctrl := &Controller{service: svc, logger: log} + ctrl.RegisterHTTPRoutes(router) + registerWSHandlers(dispatcher, svc) +} + +// Controller holds HTTP route handlers for the Jira integration. +type Controller struct { + service *Service + logger *logger.Logger +} + +// RegisterHTTPRoutes attaches the Jira HTTP endpoints to router. +func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { + api := router.Group("/api/v1/jira") + api.GET("/config", c.httpGetConfig) + api.POST("/config", c.httpSetConfig) + api.DELETE("/config", c.httpDeleteConfig) + api.POST("/config/test", c.httpTestConfig) + api.GET("/projects", c.httpListProjects) + api.GET("/tickets", c.httpSearchTickets) + api.GET("/tickets/:key", c.httpGetTicket) + api.POST("/tickets/:key/transitions", c.httpDoTransition) +} + +// --- HTTP handlers --- + +func (c *Controller) httpGetConfig(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + cfg, err := c.service.GetConfig(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if cfg == nil { + ctx.Status(http.StatusNoContent) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpSetConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrInvalidConfig) { + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpDeleteConfig(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + if err := c.service.DeleteConfig(ctx.Request.Context(), workspaceID); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTestConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + result, err := c.service.TestConnection(ctx.Request.Context(), &req) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpListProjects(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + projects, err := c.service.ListProjects(ctx.Request.Context(), workspaceID) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"projects": projects}) +} + +func (c *Controller) httpSearchTickets(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + jql := ctx.Query("jql") + pageToken := ctx.Query("page_token") + maxResults, _ := strconv.Atoi(ctx.Query("max_results")) + result, err := c.service.SearchTickets(ctx.Request.Context(), workspaceID, jql, pageToken, maxResults) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpGetTicket(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + key := ctx.Param("key") + ticket, err := c.service.GetTicket(ctx.Request.Context(), workspaceID, key) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, ticket) +} + +func (c *Controller) httpDoTransition(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + key := ctx.Param("key") + var req struct { + TransitionID string `json:"transitionId"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || req.TransitionID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "transitionId required"}) + return + } + if err := c.service.DoTransition(ctx.Request.Context(), workspaceID, key, req.TransitionID); err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"transitioned": true}) +} + +// errCodeJiraNotConfigured is the wire-level code surfaced to the UI when the +// workspace has no saved Jira credentials. The same string is used by both the +// HTTP and WebSocket layers so the frontend can branch on a single value. +const errCodeJiraNotConfigured = "JIRA_NOT_CONFIGURED" + +// writeClientError maps service-level errors to HTTP responses. ErrNotConfigured +// surfaces as 503 so the UI can prompt the user to configure Jira; upstream +// API errors propagate their status codes. +func (c *Controller) writeClientError(ctx *gin.Context, err error) { + if errors.Is(err, ErrNotConfigured) { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Jira is not configured for this workspace", + "code": errCodeJiraNotConfigured, + }) + return + } + var apiErr *APIError + if errors.As(err, &apiErr) { + status := http.StatusInternalServerError + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: + status = apiErr.StatusCode + } + // 3xx from the upstream means Atlassian redirected to login (step-up + // auth, expired session, etc.); treat it as unauthorized for the UI. + if apiErr.StatusCode >= 300 && apiErr.StatusCode < 400 { + status = http.StatusUnauthorized + } + ctx.JSON(status, gin.H{"error": apiErr.Error()}) + return + } + c.logger.Warn("jira handler error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +// --- WebSocket handlers --- + +func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service) { + dispatcher.RegisterFunc(ws.ActionJiraConfigGet, wsGetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionJiraConfigSet, wsSetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionJiraConfigDelete, wsDeleteConfig(svc)) + dispatcher.RegisterFunc(ws.ActionJiraConfigTest, wsTestConfig(svc)) + dispatcher.RegisterFunc(ws.ActionJiraTicketGet, wsGetTicket(svc)) + dispatcher.RegisterFunc(ws.ActionJiraTicketTransition, wsDoTransition(svc)) + dispatcher.RegisterFunc(ws.ActionJiraProjectsList, wsListProjects(svc)) +} + +// wsReply wraps the common boilerplate: try to build a response, fall back to +// an internal-error envelope if marshaling fails. +func wsReply(msg *ws.Message, payload interface{}) (*ws.Message, error) { + resp, err := ws.NewResponse(msg.ID, msg.Action, payload) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return resp, nil +} + +func wsFail(msg *ws.Message, err error) (*ws.Message, error) { + if errors.Is(err, ErrNotConfigured) { + return ws.NewError(msg.ID, msg.Action, errCodeJiraNotConfigured, err.Error(), nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) +} + +func wsGetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + } + if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil) + } + cfg, err := svc.GetConfig(ctx, p.WorkspaceID) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsSetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + cfg, err := svc.SetConfig(ctx, &req) + if err != nil { + if errors.Is(err, ErrInvalidConfig) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) + } + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsDeleteConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + } + if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil) + } + if err := svc.DeleteConfig(ctx, p.WorkspaceID); err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"deleted": true}) + } +} + +func wsTestConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + result, err := svc.TestConnection(ctx, &req) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, result) + } +} + +func wsGetTicket(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + TicketKey string `json:"ticketKey"` + } + if err := msg.ParsePayload(&p); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if p.WorkspaceID == "" || p.TicketKey == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId and ticketKey required", nil) + } + ticket, err := svc.GetTicket(ctx, p.WorkspaceID, p.TicketKey) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, ticket) + } +} + +func wsDoTransition(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + TicketKey string `json:"ticketKey"` + TransitionID string `json:"transitionId"` + } + if err := msg.ParsePayload(&p); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if p.WorkspaceID == "" || p.TicketKey == "" || p.TransitionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId, ticketKey, transitionId required", nil) + } + if err := svc.DoTransition(ctx, p.WorkspaceID, p.TicketKey, p.TransitionID); err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"transitioned": true}) + } +} + +func wsListProjects(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var p struct { + WorkspaceID string `json:"workspaceId"` + } + if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil) + } + projects, err := svc.ListProjects(ctx, p.WorkspaceID) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"projects": projects}) + } +} diff --git a/apps/backend/internal/jira/jwt.go b/apps/backend/internal/jira/jwt.go new file mode 100644 index 0000000000..b3166c21a5 --- /dev/null +++ b/apps/backend/internal/jira/jwt.go @@ -0,0 +1,36 @@ +package jira + +import ( + "encoding/base64" + "encoding/json" + "strings" + "time" +) + +// parseSessionCookieExpiry extracts the `exp` claim from a Jira session-token +// JWT. The stored secret is the bare token value (the user pastes it from +// DevTools → Application → Cookies). Returns nil when the secret isn't a JWT +// or when the `exp` claim is missing — callers treat that as "unknown" rather +// than an error, since some tenant tokens are opaque. +func parseSessionCookieExpiry(secret string) *time.Time { + parts := strings.Split(secret, ".") + if len(parts) != 3 { + return nil + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + // Some JWTs include padding; try the padded decoder as a fallback. + raw, err = base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 { + return nil + } + t := time.Unix(claims.Exp, 0).UTC() + return &t +} diff --git a/apps/backend/internal/jira/jwt_test.go b/apps/backend/internal/jira/jwt_test.go new file mode 100644 index 0000000000..bdeb35974f --- /dev/null +++ b/apps/backend/internal/jira/jwt_test.go @@ -0,0 +1,52 @@ +package jira + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" +) + +func makeJWT(t *testing.T, payload map[string]interface{}) string { + t.Helper() + header := `{"alg":"none","typ":"JWT"}` + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + enc := func(s []byte) string { return base64.RawURLEncoding.EncodeToString(s) } + return enc([]byte(header)) + "." + enc(body) + ".sig" +} + +func TestParseSessionCookieExpiry(t *testing.T) { + future := time.Now().Add(30 * 24 * time.Hour).Unix() + jwt := makeJWT(t, map[string]interface{}{"exp": future, "sub": "user"}) + + cases := []struct { + name string + input string + want int64 + }{ + {"jwt", jwt, future}, + {"not_a_jwt", "opaque-value", 0}, + {"jwt_without_exp", makeJWT(t, map[string]interface{}{"sub": "user"}), 0}, + {"empty", "", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseSessionCookieExpiry(tc.input) + if tc.want == 0 { + if got != nil { + t.Fatalf("want nil, got %v", got) + } + return + } + if got == nil { + t.Fatalf("want time=%d, got nil", tc.want) + } + if got.Unix() != tc.want { + t.Fatalf("want %d, got %d", tc.want, got.Unix()) + } + }) + } +} diff --git a/apps/backend/internal/jira/models.go b/apps/backend/internal/jira/models.go new file mode 100644 index 0000000000..c01ae370c7 --- /dev/null +++ b/apps/backend/internal/jira/models.go @@ -0,0 +1,120 @@ +// Package jira implements the Jira/Atlassian Cloud integration: workspace-scoped +// configuration storage, a REST client for tickets and transitions, and the HTTP +// and WebSocket handlers that expose these capabilities to the frontend. +package jira + +import "time" + +// Auth method identifiers persisted in JiraConfig.AuthMethod. +const ( + AuthMethodAPIToken = "api_token" + AuthMethodSessionCookie = "session_cookie" +) + +// JiraConfig is the workspace-scoped configuration for the Jira integration. +// The secret value (API token or session cookie) is stored separately in the +// encrypted secret store under the key returned by SecretKeyForWorkspace. +type JiraConfig struct { + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + SiteURL string `json:"siteUrl" db:"site_url"` + Email string `json:"email" db:"email"` + AuthMethod string `json:"authMethod" db:"auth_method"` + DefaultProjectKey string `json:"defaultProjectKey" db:"default_project_key"` + HasSecret bool `json:"hasSecret" db:"-"` + // SecretExpiresAt is populated for session_cookie auth when the cookie is + // a JWT (cloud.session.token / tenant.session.token). Nil for api_token or + // opaque session cookies. + SecretExpiresAt *time.Time `json:"secretExpiresAt,omitempty" db:"-"` + // LastCheckedAt / LastOk / LastError are written by the background auth + // poller. They let the UI render a "connected/disconnected + checked Xs ago" + // indicator without doing its own probing. + LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty" db:"last_checked_at"` + LastOk bool `json:"lastOk" db:"last_ok"` + LastError string `json:"lastError,omitempty" db:"last_error"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// SetConfigRequest is the payload sent by the UI to create or update the +// workspace's Jira configuration. When Secret is empty on update, the existing +// secret is retained; when non-empty it replaces the stored value. +type SetConfigRequest struct { + WorkspaceID string `json:"workspaceId"` + SiteURL string `json:"siteUrl"` + Email string `json:"email"` + AuthMethod string `json:"authMethod"` + DefaultProjectKey string `json:"defaultProjectKey"` + Secret string `json:"secret"` +} + +// TestConnectionResult reports what the backend learned when pinging Jira with +// the supplied credentials. It is used both to verify newly-entered credentials +// and to surface a meaningful error to the UI when they fail. +type TestConnectionResult struct { + OK bool `json:"ok"` + AccountID string `json:"accountId,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + Error string `json:"error,omitempty"` +} + +// JiraTicket is the subset of Atlassian's issue payload that Kandev consumes. +// Kept small intentionally: the UI needs enough to prefill a task, show status, +// and surface a few familiar fields (assignee, reporter, priority) in the +// popover so users don't have to switch tabs to Jira. +type JiraTicket struct { + Key string `json:"key"` + Summary string `json:"summary"` + Description string `json:"description"` + StatusID string `json:"statusId"` + StatusName string `json:"statusName"` + StatusCategory string `json:"statusCategory"` // "new" | "indeterminate" | "done" + ProjectKey string `json:"projectKey"` + IssueType string `json:"issueType"` + IssueTypeIcon string `json:"issueTypeIcon,omitempty"` + Priority string `json:"priority,omitempty"` + PriorityIcon string `json:"priorityIcon,omitempty"` + AssigneeName string `json:"assigneeName,omitempty"` + AssigneeAvatar string `json:"assigneeAvatar,omitempty"` + ReporterName string `json:"reporterName,omitempty"` + ReporterAvatar string `json:"reporterAvatar,omitempty"` + Updated string `json:"updated,omitempty"` + URL string `json:"url"` + Transitions []JiraTransition `json:"transitions"` + Fields map[string]string `json:"fields,omitempty"` +} + +// JiraTransition is one of the workflow transitions available for a ticket at +// the time of fetch. Transition IDs are stable within a project but the set of +// available transitions changes with ticket status, so the UI must re-fetch +// after a transition. +type JiraTransition struct { + ID string `json:"id"` + Name string `json:"name"` + ToStatusID string `json:"toStatusId"` + ToStatusName string `json:"toStatusName"` +} + +// JiraProject is the minimal shape used by the project selector on the settings +// page. +type JiraProject struct { + Key string `json:"key"` + Name string `json:"name"` + ID string `json:"id"` +} + +// SearchResult is a page of tickets from a JQL search. Atlassian's +// /rest/api/3/search/jql endpoint is token-paginated and returns no total +// count, so the UI relies on IsLast and NextPageToken to walk pages. +type SearchResult struct { + Tickets []JiraTicket `json:"tickets"` + MaxResults int `json:"maxResults"` + IsLast bool `json:"isLast"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +// SecretKeyForWorkspace returns the secret-store key used for the Jira token of +// a given workspace. Centralised so that the service and the store agree. +func SecretKeyForWorkspace(workspaceID string) string { + return "jira:" + workspaceID + ":token" +} diff --git a/apps/backend/internal/jira/poller.go b/apps/backend/internal/jira/poller.go new file mode 100644 index 0000000000..dfa0706830 --- /dev/null +++ b/apps/backend/internal/jira/poller.go @@ -0,0 +1,104 @@ +package jira + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// defaultAuthPollInterval is how often the auth-health poller probes each +// configured workspace. 90s is a compromise: short enough to keep +// session-cookie auth warm (Atlassian idle-times sessions out after a few +// minutes) and to surface expirations promptly in the UI, long enough that we +// don't hammer Atlassian when many workspaces are configured. +const defaultAuthPollInterval = 90 * time.Second + +// Poller probes the stored Jira credentials of every configured workspace on a +// fixed cadence and persists the result on the JiraConfig row. The frontend +// reads those fields via getJiraConfig to render the connected/disconnected +// indicator without doing its own probing. +type Poller struct { + service *Service + logger *logger.Logger + interval time.Duration + + // mu guards started/cancel/wg against concurrent Start/Stop calls. + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + started bool +} + +// NewPoller returns a poller that uses the default 90s cadence. +func NewPoller(svc *Service, log *logger.Logger) *Poller { + return &Poller{service: svc, logger: log, interval: defaultAuthPollInterval} +} + +// Start launches the background loop. Calling Start more than once without +// Stop is a no-op. +func (p *Poller) Start(ctx context.Context) { + p.mu.Lock() + defer p.mu.Unlock() + if p.started || p.service == nil { + return + } + p.started = true + ctx, p.cancel = context.WithCancel(ctx) + p.wg.Add(1) + go p.loop(ctx) + p.logger.Info("Jira auth poller started") +} + +// Stop cancels the loop and waits for it to drain. +func (p *Poller) Stop() { + p.mu.Lock() + if !p.started { + p.mu.Unlock() + return + } + cancel := p.cancel + p.mu.Unlock() + if cancel != nil { + cancel() + } + p.wg.Wait() + p.mu.Lock() + p.started = false + p.mu.Unlock() + p.logger.Info("Jira auth poller stopped") +} + +func (p *Poller) loop(ctx context.Context) { + defer p.wg.Done() + // Run an initial probe immediately so the UI gets a status without waiting + // the full interval after backend startup. + p.probeAll(ctx) + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.probeAll(ctx) + } + } +} + +func (p *Poller) probeAll(ctx context.Context) { + ids, err := p.service.Store().ListConfiguredWorkspaces(ctx) + if err != nil { + p.logger.Warn("jira poller: list workspaces failed", zap.Error(err)) + return + } + for _, id := range ids { + if ctx.Err() != nil { + return + } + p.service.RecordAuthHealth(ctx, id) + } +} diff --git a/apps/backend/internal/jira/poller_test.go b/apps/backend/internal/jira/poller_test.go new file mode 100644 index 0000000000..dad0f8b983 --- /dev/null +++ b/apps/backend/internal/jira/poller_test.go @@ -0,0 +1,208 @@ +package jira + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +// pollerFixture wires a Service with an in-memory store and fake client/secret +// store, then drives the Poller's probe step directly. We don't run the +// timer-driven loop because the unit value of testing the loop is mostly that +// it ticks on a context — which is just stdlib behavior. +type pollerFixture struct { + store *Store + secrets *fakeSecretStore + client *fakeClient + svc *Service + poller *Poller +} + +func newPollerFixture(t *testing.T) *pollerFixture { + t.Helper() + f := &pollerFixture{ + store: newTestStore(t), + secrets: newFakeSecretStore(), + client: &fakeClient{}, + } + f.svc = NewService(f.store, f.secrets, func(_ *JiraConfig, _ string) Client { + return f.client + }, logger.Default()) + f.poller = NewPoller(f.svc, logger.Default()) + return f +} + +// saveConfig persists a workspace config directly via the store + secret +// fakes. We deliberately avoid Service.SetConfig here because it now fires an +// async auth probe in a goroutine — fine for production but it would race +// against the deterministic `probeAll` calls these tests make. +func (f *pollerFixture) saveConfig(t *testing.T, workspaceID, secret string) { + t.Helper() + ctx := context.Background() + if err := f.store.UpsertConfig(ctx, &JiraConfig{ + WorkspaceID: workspaceID, + SiteURL: "https://" + workspaceID + ".atlassian.net", + Email: workspaceID + "@example.com", + AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("save config %s: %v", workspaceID, err) + } + if err := f.secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), + "jira", secret); err != nil { + t.Fatalf("save secret %s: %v", workspaceID, err) + } +} + +func TestPoller_ProbeAll_RecordsSuccess(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil + } + + f.poller.probeAll(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + if cfg == nil { + t.Fatal("config disappeared") + } + if !cfg.LastOk { + t.Error("expected LastOk=true after successful probe") + } + if cfg.LastError != "" { + t.Errorf("expected empty error, got %q", cfg.LastError) + } + if cfg.LastCheckedAt == nil { + t.Error("expected LastCheckedAt to be set") + } +} + +func TestPoller_ProbeAll_RecordsFailure(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + // Service convention: TestAuth returns the failure inside the result + // rather than as an error, so the UI can render the message inline. + return &TestConnectionResult{OK: false, Error: "step-up auth required"}, nil + } + + f.poller.probeAll(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + if cfg.LastOk { + t.Error("expected LastOk=false after failed probe") + } + if cfg.LastError != "step-up auth required" { + t.Errorf("expected error preserved, got %q", cfg.LastError) + } +} + +func TestPoller_ProbeAll_ClientError(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return nil, errors.New("network timeout") + } + + f.poller.probeAll(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background(), "ws-1") + if cfg.LastOk { + t.Error("expected LastOk=false on client error") + } + if cfg.LastError != "network timeout" { + t.Errorf("expected error from client preserved, got %q", cfg.LastError) + } +} + +func TestPoller_ProbeAll_NoWorkspaces(t *testing.T) { + f := newPollerFixture(t) + // Should be a clean no-op when nothing is configured. + f.poller.probeAll(context.Background()) +} + +func TestPoller_ProbeAll_MultipleWorkspaces(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-a", "tok-a") + f.saveConfig(t, "ws-b", "tok-b") + calls := 0 + f.client.testAuthFn = func() (*TestConnectionResult, error) { + calls++ + return &TestConnectionResult{OK: true}, nil + } + + f.poller.probeAll(context.Background()) + + if calls != 2 { + t.Errorf("expected 2 probe calls, got %d", calls) + } + for _, id := range []string{"ws-a", "ws-b"} { + cfg, _ := f.store.GetConfig(context.Background(), id) + if !cfg.LastOk || cfg.LastCheckedAt == nil { + t.Errorf("workspace %s missing health update: %+v", id, cfg) + } + } +} + +func TestPoller_StartStop(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + probed := make(chan struct{}, 1) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + select { + case probed <- struct{}{}: + default: + } + return &TestConnectionResult{OK: true}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f.poller.Start(ctx) + defer f.poller.Stop() + + // The loop runs an initial probe immediately on Start; the fake client + // signals on `probed` so we wait without polling. + select { + case <-probed: + case <-time.After(2 * time.Second): + t.Fatal("poller did not record an initial probe within 2s") + } +} + +func TestPoller_StartIsIdempotent(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "ws-1", "tok") + var calls int32 + probed := make(chan struct{}, 1) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + atomic.AddInt32(&calls, 1) + select { + case probed <- struct{}{}: + default: + } + return &TestConnectionResult{OK: true}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f.poller.Start(ctx) + f.poller.Start(ctx) // second call must be a no-op (no second goroutine). + + // Wait for the first probe to land. If the second Start spawned a parallel + // loop we'd see two probes back-to-back. + select { + case <-probed: + case <-time.After(2 * time.Second): + t.Fatal("poller did not run an initial probe within 2s") + } + f.poller.Stop() + + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("expected exactly 1 probe call from the initial run, got %d", got) + } +} diff --git a/apps/backend/internal/jira/provider.go b/apps/backend/internal/jira/provider.go new file mode 100644 index 0000000000..e0ebcfa136 --- /dev/null +++ b/apps/backend/internal/jira/provider.go @@ -0,0 +1,20 @@ +package jira + +import ( + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Provide builds the Jira service. Cleanup is a no-op today — the service +// holds only in-memory client caches — but the signature mirrors other +// integration providers so callers can register it uniformly. +func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) { + store, err := NewStore(writer, reader) + if err != nil { + return nil, nil, err + } + svc := NewService(store, secrets, DefaultClientFactory, log) + cleanup := func() error { return nil } + return svc, cleanup, nil +} diff --git a/apps/backend/internal/jira/service.go b/apps/backend/internal/jira/service.go new file mode 100644 index 0000000000..0cad41fe18 --- /dev/null +++ b/apps/backend/internal/jira/service.go @@ -0,0 +1,406 @@ +package jira + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// SecretStore is the subset of the secrets store the service needs. Kept small +// so tests can fake it easily. +type SecretStore interface { + Reveal(ctx context.Context, id string) (string, error) + Set(ctx context.Context, id, name, value string) error + Delete(ctx context.Context, id string) error + Exists(ctx context.Context, id string) (bool, error) +} + +// Service orchestrates Jira config storage, the per-workspace client cache, and +// the fetch/transition operations used by the WebSocket + HTTP handlers. +type Service struct { + store *Store + secrets SecretStore + log *logger.Logger + mu sync.Mutex + clientFn ClientFactory + cache map[string]Client // workspaceID → client, cleared on config change. + probeHook func(workspaceID string) +} + +// ClientFactory builds a Client for the given config + secret. Overridable so +// tests can inject fakes without touching HTTP. +type ClientFactory func(cfg *JiraConfig, secret string) Client + +// DefaultClientFactory returns a real CloudClient. +func DefaultClientFactory(cfg *JiraConfig, secret string) Client { + return NewCloudClient(cfg, secret) +} + +// NewService wires the service with a store, secret backend, and client +// factory. Pass nil for clientFn to use DefaultClientFactory. +func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *logger.Logger) *Service { + if clientFn == nil { + clientFn = DefaultClientFactory + } + return &Service{ + store: store, + secrets: secrets, + log: log, + clientFn: clientFn, + cache: make(map[string]Client), + } +} + +// GetConfig returns the workspace config enriched with a HasSecret flag so the +// UI can distinguish "configured but empty" from "needs credentials". For +// session_cookie auth, it also tries to decode the JWT in the stored cookie +// and surface its expiry so the UI can warn the user before the session dies. +func (s *Service) GetConfig(ctx context.Context, workspaceID string) (*JiraConfig, error) { + cfg, err := s.store.GetConfig(ctx, workspaceID) + if err != nil || cfg == nil { + return cfg, err + } + if s.secrets == nil { + return cfg, nil + } + key := SecretKeyForWorkspace(workspaceID) + exists, existsErr := s.secrets.Exists(ctx, key) + if existsErr != nil { + s.log.Warn("jira: secret exists check failed", + zap.String("workspace_id", workspaceID), zap.Error(existsErr)) + } + cfg.HasSecret = exists + if exists && cfg.AuthMethod == AuthMethodSessionCookie { + secret, revealErr := s.secrets.Reveal(ctx, key) + if revealErr != nil { + s.log.Warn("jira: secret reveal failed", + zap.String("workspace_id", workspaceID), zap.Error(revealErr)) + } else { + cfg.SecretExpiresAt = parseSessionCookieExpiry(secret) + } + } + return cfg, nil +} + +// ErrInvalidConfig is returned by SetConfig when the request fails validation +// (missing workspace, bad auth method, etc.). Callers map it to HTTP 400. +var ErrInvalidConfig = errors.New("jira: invalid configuration") + +// SetConfig is upsert: it writes the workspace row and, when a new secret is +// provided, stores it in the encrypted secret store. An empty Secret means +// "keep the existing token" — this lets the UI edit auxiliary fields without +// forcing the user to paste the token again. +func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*JiraConfig, error) { + if err := validateConfigRequest(req); err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) + } + cfg := &JiraConfig{ + WorkspaceID: req.WorkspaceID, + SiteURL: normalizeSiteURL(req.SiteURL), + Email: req.Email, + AuthMethod: req.AuthMethod, + DefaultProjectKey: req.DefaultProjectKey, + } + if err := s.store.UpsertConfig(ctx, cfg); err != nil { + return nil, fmt.Errorf("upsert jira config: %w", err) + } + if req.Secret != "" && s.secrets != nil { + if err := s.secrets.Set(ctx, + SecretKeyForWorkspace(req.WorkspaceID), + "Jira token ("+req.WorkspaceID+")", + req.Secret, + ); err != nil { + return nil, fmt.Errorf("store jira secret: %w", err) + } + } + s.invalidateClient(req.WorkspaceID) + // Probe asynchronously so a slow Atlassian doesn't stall the save response. + // RecordAuthHealth manages its own probe timeout, so a fresh + // context.Background() is enough — the request ctx may be cancelled when + // this returns, but the probe and the DB write must still complete. + go func(workspaceID string) { + s.RecordAuthHealth(context.Background(), workspaceID) + }(req.WorkspaceID) + return s.GetConfig(ctx, req.WorkspaceID) +} + +// DeleteConfig removes both the config row and the stored secret. A failure to +// delete the secret is logged (not returned) so the config row deletion still +// surfaces success — the orphaned secret is rare and the user can retry by +// re-saving + deleting again. +func (s *Service) DeleteConfig(ctx context.Context, workspaceID string) error { + if err := s.store.DeleteConfig(ctx, workspaceID); err != nil { + return err + } + if s.secrets != nil { + if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil { + s.log.Warn("jira: secret delete failed", + zap.String("workspace_id", workspaceID), zap.Error(err)) + } + } + s.invalidateClient(workspaceID) + return nil +} + +// TestConnection validates credentials either from a fresh SetConfigRequest +// (before persisting) or from the stored config (after saving). Returns a +// structured result rather than an error so the UI can render the failure +// inline. +func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { + cfg, secret, err := s.resolveCredentials(ctx, req) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + client := s.clientFn(cfg, secret) + return client.TestAuth(ctx) +} + +// ProbeAuth validates the stored credentials for a workspace by hitting the +// cheapest authenticated endpoint. Used by the background auth-health poller +// to detect expired session cookies / step-up auth before the user clicks +// through to a tab that would 303. Errors are returned as a result so the +// poller can persist them as a failure rather than dropping them. +func (s *Service) ProbeAuth(ctx context.Context, workspaceID string) (*TestConnectionResult, error) { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return client.TestAuth(ctx) +} + +// Store exposes the underlying store so background workers (e.g. the auth +// health poller) can persist state without re-implementing the secrets+config +// resolution that the service already encapsulates. +func (s *Service) Store() *Store { + return s.store +} + +// authProbeTimeout caps a single auth-health probe so a slow workspace can't +// stall the caller. The /myself endpoint typically responds in <500ms. +const authProbeTimeout = 15 * time.Second + +// authHealthWriteTimeout bounds the DB write that persists the probe outcome. +// Always derived from a fresh context.Background so a probe that exhausted its +// own deadline can still record the failure. +const authHealthWriteTimeout = 5 * time.Second + +// SetProbeHook installs a callback fired at the end of each RecordAuthHealth +// call (after the DB write). Production code never sets this; tests use it to +// synchronize on probe completion without sleep-polling. +func (s *Service) SetProbeHook(fn func(workspaceID string)) { + s.mu.Lock() + defer s.mu.Unlock() + s.probeHook = fn +} + +// RecordAuthHealth probes the workspace's stored credentials and writes the +// outcome onto the JiraConfig row. Used both at config-save time (so the UI +// reflects the new credentials immediately) and by the background poller. +// Errors during the persist step are logged but not returned: this is a +// best-effort health signal, never the source of truth for callers. +func (s *Service) RecordAuthHealth(ctx context.Context, workspaceID string) { + probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) + defer cancel() + res, err := s.ProbeAuth(probeCtx, workspaceID) + ok := err == nil && res != nil && res.OK + errMsg := "" + switch { + case err != nil: + errMsg = err.Error() + case res != nil && !res.OK: + errMsg = res.Error + } + // Detach the DB write from ctx: a probe that exhausted its 15s deadline + // would otherwise pass an already-expired context to UpdateAuthHealth and + // drop the failure record on the floor (so the UI never flips to "auth + // failed" until the next poll). + writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) + defer writeCancel() + if updateErr := s.store.UpdateAuthHealth(writeCtx, workspaceID, ok, errMsg, time.Now().UTC()); updateErr != nil { + s.log.Warn("jira: update auth health failed", + zap.String("workspace_id", workspaceID), zap.Error(updateErr)) + } + s.mu.Lock() + hook := s.probeHook + s.mu.Unlock() + if hook != nil { + hook(workspaceID) + } +} + +// GetTicket loads a Jira ticket by key using the workspace's stored +// credentials. +func (s *Service) GetTicket(ctx context.Context, workspaceID, ticketKey string) (*JiraTicket, error) { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.GetTicket(ctx, ticketKey) +} + +// DoTransition applies a transition to a ticket. +func (s *Service) DoTransition(ctx context.Context, workspaceID, ticketKey, transitionID string) error { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return err + } + return client.DoTransition(ctx, ticketKey, transitionID) +} + +// ListProjects is used by the settings UI to populate the project selector. +func (s *Service) ListProjects(ctx context.Context, workspaceID string) ([]JiraProject, error) { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.ListProjects(ctx) +} + +// SearchTickets runs a JQL search for the workspace, returning a page of +// tickets. pageToken is the cursor returned in the previous page's +// NextPageToken; pass "" for the first page. +func (s *Service) SearchTickets(ctx context.Context, workspaceID, jql, pageToken string, maxResults int) (*SearchResult, error) { + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.SearchTickets(ctx, jql, pageToken, maxResults) +} + +// clientFor returns a cached client, creating one if needed. The cache is +// invalidated whenever the config changes so stale credentials never linger. +func (s *Service) clientFor(ctx context.Context, workspaceID string) (Client, error) { + s.mu.Lock() + if c, ok := s.cache[workspaceID]; ok { + s.mu.Unlock() + return c, nil + } + s.mu.Unlock() + + cfg, err := s.store.GetConfig(ctx, workspaceID) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, ErrNotConfigured + } + secret := "" + if s.secrets != nil { + secret, err = s.secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID)) + if err != nil { + // Don't conflate a transient secret-store failure with "user never + // configured Jira". The UI gates on ErrNotConfigured to render a + // configure-CTA; surfacing the real error gives the user a path to + // retry rather than leaving them stuck. + return nil, fmt.Errorf("read jira secret: %w", err) + } + if secret == "" { + return nil, ErrNotConfigured + } + } + client := s.clientFn(cfg, secret) + s.mu.Lock() + defer s.mu.Unlock() + // Re-check: another caller may have populated the cache while we were + // fetching the config and secret. Returning the existing client keeps the + // cache identity stable so callers comparing pointers don't see flapping. + if existing, ok := s.cache[workspaceID]; ok { + return existing, nil + } + s.cache[workspaceID] = client + return client, nil +} + +// invalidateClient drops a cached client so the next request rebuilds it with +// fresh credentials. +func (s *Service) invalidateClient(workspaceID string) { + s.mu.Lock() + delete(s.cache, workspaceID) + s.mu.Unlock() +} + +// resolveCredentials picks the credentials to test: if the request carries a +// secret, use it inline (pre-save); otherwise fall back to the stored secret +// (post-save re-test). +func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*JiraConfig, string, error) { + cfg := &JiraConfig{ + WorkspaceID: req.WorkspaceID, + SiteURL: normalizeSiteURL(req.SiteURL), + Email: req.Email, + AuthMethod: req.AuthMethod, + } + if req.Secret != "" { + return cfg, req.Secret, nil + } + if s.secrets == nil { + return nil, "", errors.New("no secret store configured") + } + secret, err := s.secrets.Reveal(ctx, SecretKeyForWorkspace(req.WorkspaceID)) + if err != nil { + // Don't conflate a transient secret-store failure with "no token + // stored". Surfacing the real error gives the user a path to retry + // instead of telling them to paste a token they already have. + s.log.Warn("jira: secret reveal failed", + zap.String("workspace_id", req.WorkspaceID), zap.Error(err)) + return nil, "", fmt.Errorf("read jira secret: %w", err) + } + if secret == "" { + return nil, "", errors.New("no token stored — paste one to test") + } + // Merge with persisted config so the test uses saved site/email if the + // caller only passed a partial request. + if stored, _ := s.store.GetConfig(ctx, req.WorkspaceID); stored != nil { + if cfg.SiteURL == "" { + cfg.SiteURL = stored.SiteURL + } + if cfg.Email == "" { + cfg.Email = stored.Email + } + if cfg.AuthMethod == "" { + cfg.AuthMethod = stored.AuthMethod + } + } + return cfg, secret, nil +} + +func validateConfigRequest(req *SetConfigRequest) error { + if req.WorkspaceID == "" { + return errors.New("workspaceId required") + } + if req.SiteURL == "" { + return errors.New("siteUrl required") + } + switch req.AuthMethod { + case AuthMethodAPIToken: + if req.Email == "" { + return errors.New("email required for api_token auth") + } + case AuthMethodSessionCookie: + // email is optional for session cookies. + default: + return fmt.Errorf("unknown auth method: %q", req.AuthMethod) + } + return nil +} + +// normalizeSiteURL trims trailing slashes and prepends https:// when the user +// typed only a hostname (e.g. "acme.atlassian.net"). Without a scheme the Go +// HTTP client fails with "unsupported protocol scheme". +func normalizeSiteURL(raw string) string { + s := strings.TrimSpace(raw) + s = strings.TrimRight(s, "/") + if s == "" { + return s + } + if !strings.Contains(s, "://") { + s = "https://" + s + } + return s +} diff --git a/apps/backend/internal/jira/service_test.go b/apps/backend/internal/jira/service_test.go new file mode 100644 index 0000000000..269b61a941 --- /dev/null +++ b/apps/backend/internal/jira/service_test.go @@ -0,0 +1,387 @@ +package jira + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +// fakeSecretStore is an in-memory SecretStore for tests. +type fakeSecretStore struct { + mu sync.Mutex + secrets map[string]string +} + +func newFakeSecretStore() *fakeSecretStore { + return &fakeSecretStore{secrets: map[string]string{}} +} + +func (f *fakeSecretStore) Reveal(_ context.Context, id string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.secrets[id] + if !ok { + return "", errors.New("not found") + } + return v, nil +} + +func (f *fakeSecretStore) Set(_ context.Context, id, _, value string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.secrets[id] = value + return nil +} + +func (f *fakeSecretStore) Delete(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.secrets, id) + return nil +} + +func (f *fakeSecretStore) Exists(_ context.Context, id string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.secrets[id] + return ok, nil +} + +// fakeClient is an in-memory Client for verifying service plumbing. +type fakeClient struct { + testAuthFn func() (*TestConnectionResult, error) + getTicketFn func(key string) (*JiraTicket, error) + transitionFn func(key, id string) error + listProjects func() ([]JiraProject, error) + transitionLog []string // "key:id" +} + +func (c *fakeClient) TestAuth(_ context.Context) (*TestConnectionResult, error) { + if c.testAuthFn != nil { + return c.testAuthFn() + } + return &TestConnectionResult{OK: true}, nil +} +func (c *fakeClient) GetTicket(_ context.Context, k string) (*JiraTicket, error) { + if c.getTicketFn != nil { + return c.getTicketFn(k) + } + return &JiraTicket{Key: k}, nil +} +func (c *fakeClient) ListTransitions(_ context.Context, _ string) ([]JiraTransition, error) { + return nil, nil +} +func (c *fakeClient) DoTransition(_ context.Context, key, id string) error { + c.transitionLog = append(c.transitionLog, key+":"+id) + if c.transitionFn != nil { + return c.transitionFn(key, id) + } + return nil +} +func (c *fakeClient) ListProjects(_ context.Context) ([]JiraProject, error) { + if c.listProjects != nil { + return c.listProjects() + } + return nil, nil +} +func (c *fakeClient) SearchTickets(_ context.Context, _, _ string, _ int) (*SearchResult, error) { + return &SearchResult{}, nil +} + +type svcFixture struct { + svc *Service + store *Store + secrets *fakeSecretStore + client *fakeClient + // atomic so the async probe goroutine spawned by SetConfig can call + // clientFor (and the injected factory) without racing the test thread. + factoryHit atomic.Int32 + probed chan string +} + +func newSvcFixture(t *testing.T) *svcFixture { + t.Helper() + f := &svcFixture{ + store: newTestStore(t), + secrets: newFakeSecretStore(), + client: &fakeClient{}, + probed: make(chan string, 8), + } + f.svc = NewService(f.store, f.secrets, func(_ *JiraConfig, _ string) Client { + f.factoryHit.Add(1) + return f.client + }, logger.Default()) + f.svc.SetProbeHook(func(workspaceID string) { + select { + case f.probed <- workspaceID: + default: + } + }) + return f +} + +func TestService_SetConfig_UpsertsAndStoresSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + cfg, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", + SiteURL: "https://acme.atlassian.net/", + Email: "u@example.com", + AuthMethod: AuthMethodAPIToken, + Secret: "tok1", + }) + if err != nil { + t.Fatalf("set: %v", err) + } + if cfg.SiteURL != "https://acme.atlassian.net" { + t.Errorf("site url not trimmed: %q", cfg.SiteURL) + } + if !cfg.HasSecret { + t.Error("expected HasSecret=true") + } + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("ws-1")); got != "tok1" { + t.Errorf("secret stored = %q", got) + } +} + +func TestService_SetConfig_ProbesAuthImmediately(t *testing.T) { + f := newSvcFixture(t) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil + } + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "tok", + }); err != nil { + t.Fatalf("set: %v", err) + } + cfg := waitForAuthProbe(t, f, "ws-1") + if !cfg.LastOk { + t.Errorf("expected LastOk=true after async probe, got %+v", cfg) + } + if cfg.LastCheckedAt == nil { + t.Error("expected LastCheckedAt to be set after async probe") + } +} + +func TestService_SetConfig_PersistsProbeFailure(t *testing.T) { + f := newSvcFixture(t) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: false, Error: "401 unauthorized"}, nil + } + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "bad", + }); err != nil { + t.Fatalf("set: %v", err) + } + cfg := waitForAuthProbe(t, f, "ws-1") + if cfg.LastOk { + t.Error("expected LastOk=false after failed probe") + } + if cfg.LastError != "401 unauthorized" { + t.Errorf("expected probe error preserved, got %q", cfg.LastError) + } +} + +// waitForAuthProbe blocks until the async probe spawned by SetConfig has +// completed (signaled via the fixture's probeHook), then returns the persisted +// config. A 2s ceiling guards against bugs that prevent the hook from firing. +func waitForAuthProbe(t *testing.T, f *svcFixture, workspaceID string) *JiraConfig { + t.Helper() + for { + select { + case got := <-f.probed: + if got != workspaceID { + continue + } + cfg, err := f.svc.GetConfig(context.Background(), workspaceID) + if err != nil { + t.Fatalf("get config after probe: %v", err) + } + return cfg + case <-time.After(2 * time.Second): + t.Fatalf("async probe hook did not fire for %q within 2s", workspaceID) + return nil + } + } +} + +func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "first", + }) + if err != nil { + t.Fatalf("initial: %v", err) + } + _, err = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "new@x", + AuthMethod: AuthMethodAPIToken, Secret: "", + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if got, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace("ws-1")); got != "first" { + t.Errorf("secret should be preserved, got %q", got) + } +} + +func TestService_SetConfig_InvalidatesClientCache(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "t", + }) + // SetConfig spawns an async probe that also builds the client; wait for it + // so the rest of the test sees a stable factoryHit count. + waitForAuthProbe(t, f, "ws-1") + if _, err := f.svc.GetTicket(ctx, "ws-1", "A-1"); err != nil { + t.Fatalf("get1: %v", err) + } + hits := f.factoryHit.Load() + // Second call reuses cached client. + if _, err := f.svc.GetTicket(ctx, "ws-1", "A-2"); err != nil { + t.Fatalf("get2: %v", err) + } + if got := f.factoryHit.Load(); got != hits { + t.Errorf("factory should be cached, hits %d→%d", hits, got) + } + // Updating config invalidates cache. Wait for the async probe spawned by + // SetConfig to finish before sampling factoryHit so the second probe can't + // race with the GetTicket assertion. + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "t2", + }) + waitForAuthProbe(t, f, "ws-1") + if _, err := f.svc.GetTicket(ctx, "ws-1", "A-3"); err != nil { + t.Fatalf("get3: %v", err) + } + if got := f.factoryHit.Load(); got <= hits { + t.Errorf("factory should rebuild after config change, hits=%d", got) + } +} + +func TestService_Validation(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + cases := []struct { + name string + req SetConfigRequest + }{ + {"missing ws", SetConfigRequest{SiteURL: "x", AuthMethod: AuthMethodAPIToken, Email: "e"}}, + {"missing site", SetConfigRequest{WorkspaceID: "w", AuthMethod: AuthMethodAPIToken, Email: "e"}}, + {"missing email api_token", SetConfigRequest{WorkspaceID: "w", SiteURL: "x", AuthMethod: AuthMethodAPIToken}}, + {"bad auth", SetConfigRequest{WorkspaceID: "w", SiteURL: "x", AuthMethod: "bogus"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := f.svc.SetConfig(ctx, &tc.req); err == nil { + t.Error("expected validation error") + } + }) + } +} + +func TestService_TestConnection_InlineSecret(t *testing.T) { + f := newSvcFixture(t) + called := false + f.client.testAuthFn = func() (*TestConnectionResult, error) { + called = true + return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil + } + res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "inline", + }) + if err != nil || !called { + t.Fatalf("called=%v err=%v", called, err) + } + if !res.OK || res.DisplayName != "Alice" { + t.Errorf("result: %+v", res) + } +} + +func TestService_TestConnection_NoStoredSecret_ReturnsFailure(t *testing.T) { + f := newSvcFixture(t) + res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ + WorkspaceID: "ws-nope", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if res.OK { + t.Fatal("expected OK=false") + } +} + +func TestService_DeleteConfig_RemovesSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "t", + }) + if err := f.svc.DeleteConfig(ctx, "ws-1"); err != nil { + t.Fatalf("delete: %v", err) + } + if exists, _ := f.secrets.Exists(ctx, SecretKeyForWorkspace("ws-1")); exists { + t.Error("secret should be removed") + } + cfg, _ := f.svc.GetConfig(ctx, "ws-1") + if cfg != nil { + t.Errorf("expected config gone, got %+v", cfg) + } +} + +func TestService_GetTicket_UnconfiguredWorkspace(t *testing.T) { + f := newSvcFixture(t) + _, err := f.svc.GetTicket(context.Background(), "ws-nope", "X-1") + if !errors.Is(err, ErrNotConfigured) { + t.Errorf("expected ErrNotConfigured, got %v", err) + } +} + +func TestService_GetTicket_RevealError_NotConfusedWithUnconfigured(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if err := f.store.UpsertConfig(ctx, &JiraConfig{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("seed config: %v", err) + } + _, err := f.svc.GetTicket(ctx, "ws-1", "X-1") + if err == nil { + t.Fatal("expected error") + } + if errors.Is(err, ErrNotConfigured) { + t.Errorf("transient Reveal failure must not be ErrNotConfigured: %v", err) + } +} + +func TestService_DoTransition_PassThrough(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, Secret: "t", + }) + if err := f.svc.DoTransition(ctx, "ws-1", "PROJ-9", "31"); err != nil { + t.Fatalf("transition: %v", err) + } + if len(f.client.transitionLog) != 1 || f.client.transitionLog[0] != "PROJ-9:31" { + t.Errorf("log: %v", f.client.transitionLog) + } +} diff --git a/apps/backend/internal/jira/store.go b/apps/backend/internal/jira/store.go new file mode 100644 index 0000000000..304080a230 --- /dev/null +++ b/apps/backend/internal/jira/store.go @@ -0,0 +1,177 @@ +package jira + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +// Store persists Jira workspace configurations. Secret values are delegated to +// the shared encrypted secret store and not stored here. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewStore creates a new Store and initializes the schema if needed. +func NewStore(writer, reader *sqlx.DB) (*Store, error) { + s := &Store{db: writer, ro: reader} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("jira schema init: %w", err) + } + return s, nil +} + +const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS jira_configs ( + workspace_id TEXT PRIMARY KEY, + site_url TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + auth_method TEXT NOT NULL, + default_project_key TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); +` + +// addedColumns lists columns introduced after the initial schema. SQLite has no +// portable `ADD COLUMN IF NOT EXISTS`, so we ask sqlite_master for the existing +// CREATE TABLE statement and only ALTER when the column name is missing. +var addedColumns = []struct { + name string + sql string +}{ + {"last_checked_at", "ALTER TABLE jira_configs ADD COLUMN last_checked_at DATETIME"}, + {"last_ok", "ALTER TABLE jira_configs ADD COLUMN last_ok INTEGER NOT NULL DEFAULT 0"}, + {"last_error", "ALTER TABLE jira_configs ADD COLUMN last_error TEXT NOT NULL DEFAULT ''"}, +} + +func (s *Store) initSchema() error { + if _, err := s.db.Exec(createTablesSQL); err != nil { + return err + } + return s.migrateAddedColumns() +} + +// migrateAddedColumns applies ALTER TABLE statements for columns introduced +// after the initial schema. Existing databases created with the old CREATE +// TABLE need these columns backfilled; new databases already have them from +// createTablesSQL and the ALTERs are skipped. +func (s *Store) migrateAddedColumns() error { + existing, err := s.tableColumns("jira_configs") + if err != nil { + return err + } + for _, col := range addedColumns { + if _, ok := existing[col.name]; ok { + continue + } + if _, err := s.db.Exec(col.sql); err != nil { + return fmt.Errorf("add column %s: %w", col.name, err) + } + } + return nil +} + +func (s *Store) tableColumns(table string) (map[string]struct{}, error) { + rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + cols := make(map[string]struct{}) + for rows.Next() { + var ( + cid int + name string + ctype string + notnull int + dflt sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return nil, err + } + cols[name] = struct{}{} + } + return cols, rows.Err() +} + +const selectConfigColumns = `workspace_id, site_url, email, auth_method, default_project_key, + last_checked_at, last_ok, last_error, created_at, updated_at` + +// GetConfig returns the Jira config for a workspace, or nil when no row exists. +func (s *Store) GetConfig(ctx context.Context, workspaceID string) (*JiraConfig, error) { + var cfg JiraConfig + err := s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM jira_configs WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertConfig inserts or updates the config row for a workspace. It never +// touches the secret store — callers must persist the token separately. The +// last_* health columns are deliberately not touched here; the poller owns +// those and writes them via UpdateAuthHealth. +func (s *Store) UpsertConfig(ctx context.Context, cfg *JiraConfig) error { + now := time.Now().UTC() + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = now + } + cfg.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO jira_configs (workspace_id, site_url, email, auth_method, default_project_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + site_url = excluded.site_url, + email = excluded.email, + auth_method = excluded.auth_method, + default_project_key = excluded.default_project_key, + updated_at = excluded.updated_at`, + cfg.WorkspaceID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.DefaultProjectKey, cfg.CreatedAt, cfg.UpdatedAt) + return err +} + +// DeleteConfig removes the Jira config row for a workspace. Secrets must be +// cleared separately by the caller. +func (s *Store) DeleteConfig(ctx context.Context, workspaceID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM jira_configs WHERE workspace_id = ?`, workspaceID) + return err +} + +// ListConfiguredWorkspaces returns the IDs of all workspaces that have a Jira +// config row. Used by the auth-health poller to know which workspaces to probe. +func (s *Store) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) { + var ids []string + err := s.ro.SelectContext(ctx, &ids, + `SELECT workspace_id FROM jira_configs ORDER BY workspace_id`) + if err != nil { + return nil, err + } + return ids, nil +} + +// UpdateAuthHealth records the result of a credential probe for a workspace. +// errMsg is the empty string when ok is true. If the workspace row no longer +// exists (e.g. the user removed the config concurrently with the poller), the +// update is a silent no-op rather than an error. +func (s *Store) UpdateAuthHealth(ctx context.Context, workspaceID string, ok bool, errMsg string, checkedAt time.Time) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE jira_configs + SET last_checked_at = ?, last_ok = ?, last_error = ? + WHERE workspace_id = ?`, + checkedAt, ok, errMsg, workspaceID) + return err +} diff --git a/apps/backend/internal/jira/store_test.go b/apps/backend/internal/jira/store_test.go new file mode 100644 index 0000000000..1a101e3e5b --- /dev/null +++ b/apps/backend/internal/jira/store_test.go @@ -0,0 +1,174 @@ +package jira + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func TestStore_UpsertGetDelete(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + cfg := &JiraConfig{ + WorkspaceID: "ws-1", + SiteURL: "https://acme.atlassian.net", + Email: "user@example.com", + AuthMethod: AuthMethodAPIToken, + DefaultProjectKey: "PROJ", + } + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, err := store.GetConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected config, got nil") + } + if got.SiteURL != cfg.SiteURL || got.Email != cfg.Email { + t.Errorf("round-trip mismatch: %+v vs %+v", got, cfg) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Error("timestamps not set") + } + + // Update-in-place + cfg.Email = "other@example.com" + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("update upsert: %v", err) + } + got2, _ := store.GetConfig(ctx, "ws-1") + if got2.Email != "other@example.com" { + t.Errorf("expected email update, got %q", got2.Email) + } + + if err := store.DeleteConfig(ctx, "ws-1"); err != nil { + t.Fatalf("delete: %v", err) + } + gone, err := store.GetConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if gone != nil { + t.Errorf("expected nil after delete, got %+v", gone) + } +} + +func TestStore_GetConfig_Missing(t *testing.T) { + store := newTestStore(t) + cfg, err := store.GetConfig(context.Background(), "does-not-exist") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg != nil { + t.Errorf("expected nil for missing config, got %+v", cfg) + } +} + +func TestStore_ListConfiguredWorkspaces(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + mustUpsert := func(id string) { + if err := store.UpsertConfig(ctx, &JiraConfig{ + WorkspaceID: id, + SiteURL: "https://" + id + ".atlassian.net", + Email: id + "@example.com", + AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("upsert %s: %v", id, err) + } + } + mustUpsert("ws-b") + mustUpsert("ws-a") + ids, err := store.ListConfiguredWorkspaces(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(ids) != 2 || ids[0] != "ws-a" || ids[1] != "ws-b" { + t.Errorf("expected sorted [ws-a ws-b], got %v", ids) + } +} + +func TestStore_UpdateAuthHealth(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.UpsertConfig(ctx, &JiraConfig{ + WorkspaceID: "ws-1", + SiteURL: "https://acme.atlassian.net", + Email: "u@example.com", + AuthMethod: AuthMethodAPIToken, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + + // Initial state: no health recorded yet. + cfg, _ := store.GetConfig(ctx, "ws-1") + if cfg.LastCheckedAt != nil { + t.Errorf("expected nil last_checked_at on fresh row, got %v", cfg.LastCheckedAt) + } + if cfg.LastOk { + t.Error("expected last_ok=false on fresh row") + } + + // Record success. + now := time.Now().UTC().Truncate(time.Second) + if err := store.UpdateAuthHealth(ctx, "ws-1", true, "", now); err != nil { + t.Fatalf("update ok: %v", err) + } + cfg, _ = store.GetConfig(ctx, "ws-1") + if !cfg.LastOk { + t.Error("expected last_ok=true after successful probe") + } + if cfg.LastError != "" { + t.Errorf("expected empty last_error, got %q", cfg.LastError) + } + if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(now) { + t.Errorf("expected last_checked_at=%v, got %v", now, cfg.LastCheckedAt) + } + + // Record failure — error string is preserved, ok flips back to false. + failAt := now.Add(time.Minute) + if err := store.UpdateAuthHealth(ctx, "ws-1", false, "step-up required", failAt); err != nil { + t.Fatalf("update fail: %v", err) + } + cfg, _ = store.GetConfig(ctx, "ws-1") + if cfg.LastOk { + t.Error("expected last_ok=false after failure") + } + if cfg.LastError != "step-up required" { + t.Errorf("expected last_error preserved, got %q", cfg.LastError) + } + if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(failAt) { + t.Errorf("expected last_checked_at=%v, got %v", failAt, cfg.LastCheckedAt) + } + + // Updating health for a non-existent workspace must not error (silent + // no-op — the row may have been deleted between probe and persist). + if err := store.UpdateAuthHealth(ctx, "missing", true, "", now); err != nil { + t.Errorf("expected no-op for missing row, got %v", err) + } +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index a864b449db..09651c48db 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -385,6 +385,17 @@ const ( ActionGitHubActionPresetsReset = "github.action_presets.reset" ) +// Jira integration actions +const ( + ActionJiraConfigGet = "jira.config.get" + ActionJiraConfigSet = "jira.config.set" + ActionJiraConfigDelete = "jira.config.delete" + ActionJiraConfigTest = "jira.config.test" + ActionJiraTicketGet = "jira.ticket.get" + ActionJiraTicketTransition = "jira.ticket.transition" + ActionJiraProjectsList = "jira.projects.list" +) + // Error codes const ( ErrorCodeBadRequest = "BAD_REQUEST" diff --git a/apps/web/app/jira/jira-page-client.tsx b/apps/web/app/jira/jira-page-client.tsx new file mode 100644 index 0000000000..0f32ed6cd1 --- /dev/null +++ b/apps/web/app/jira/jira-page-client.tsx @@ -0,0 +1,337 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { IconTicket } from "@tabler/icons-react"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { PageTopbar } from "@/components/page-topbar"; +import { getJiraConfig, listJiraProjects, searchJiraTickets } from "@/lib/api/domains/jira-api"; +import type { JiraProject, JiraTicket } from "@/lib/types/jira"; +import type { Workflow, WorkflowStep } from "@/lib/types/http"; +import { TicketRow } from "@/components/jira/my-jira/ticket-row"; +import { useJiraSearch } from "@/components/jira/my-jira/use-jira-search"; +import { JiraErrorMessage } from "@/components/jira/jira-ticket-common"; +import { JiraTicketDialog } from "@/components/jira/jira-ticket-dialog"; +import { + QuickTaskLauncher, + type JiraLaunchPayload, +} from "@/components/jira/my-jira/quick-task-launcher"; +import { + DEFAULT_FILTERS, + filtersToJql, + type FilterState, +} from "@/components/jira/my-jira/filter-model"; +import { DEFAULT_VIEW, useSavedViews } from "@/components/jira/my-jira/use-saved-views"; +import { ListToolbar } from "@/components/jira/my-jira/list-toolbar"; +import { FilterBar, hasActiveFilters } from "@/components/jira/my-jira/filter-bar"; +import { ResultsPagination } from "@/components/jira/my-jira/results-pagination"; +import { JqlEditor } from "@/components/jira/my-jira/jql-editor"; +import { useJiraTaskPresets } from "@/components/jira/my-jira/use-task-presets"; +import type { JiraTaskPreset } from "@/components/jira/my-jira/presets"; + +type JiraPageClientProps = { + workspaceId?: string; + workflows: Workflow[]; + steps: WorkflowStep[]; +}; + +function NotConfiguredNotice({ workspaceId }: { workspaceId?: string }) { + return ( +
+ {error} +
+ )} ++ {error} +
+ )} +
+ {COOKIE_INSTRUCTIONS}
+
+ )}
+ + {label} +
+ ); +} + +type SecretFieldPropsWithExpiry = SecretFieldProps & { secretExpiresAt?: string | null }; + +function SecretField({ + form, + loading, + update, + hasSavedSecret, + secretExpiresAt, +}: SecretFieldPropsWithExpiry) { + const isApiToken = form.authMethod === "api_token"; + return ( ++ Create a token at{" "} + + id.atlassian.com/manage-profile/security/api-tokens + +
+ )} + {!isApiToken &&+ Your Jira session expired or needs step-up authentication. Reconnect to view this + ticket. +
+{cleanJiraErrorMessage(error)}
+ )} +
+ Type {"{{"} to see available placeholders.{" "}
+ {"{{key}}"},{" "}
+ {"{{url}}"},{" "}
+ {"{{title}}"}, and{" "}
+ {"{{description}}"}{" "}
+ are substituted when the action runs.
+