feat(jira): add Jira integration for ticket browsing, import, and task linking (#705)

* feat(jira): add Jira integration for ticket browsing, import, and task linking

Adds a workspace-scoped Atlassian Cloud integration: per-workspace settings
(site URL, email, API token or session cookie), encrypted secret storage,
and a REST client for tickets, projects, search, and transitions. A
background poller probes credentials on a 90s cadence and the keepalive
runs immediately on save so the UI's connected indicator updates without
delay.

The /jira page provides ticket browsing with filter pills, custom JQL,
saved views (including JQL), and configurable task-launch presets that
prefill TaskCreateDialog. JiraLinkButton on the task top bar links an
existing task to a ticket by prepending its key to the title; the ticket
popover surfaces details, transitions, and re-auth prompts on session
expiry. Topbar shows separate icon buttons for GitHub and Jira when each
is configured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): address PR review feedback

- Token-based pagination (pageToken/isLast) replaces broken offset/total pagination
- Race-fix in service.clientFor: re-check cache after acquiring write lock
- sync.Mutex guards poller started/cancel state against concurrent Start/Stop
- Channel-based + atomic.Int32 test sync replaces time.Sleep polling in poller_test
- Async auth probe on SetConfig with detached context.Background goroutine
- Distinguish 4xx vs 5xx in httpSetConfig via ErrInvalidConfig sentinel
- Propagate 4xx APIErrors from ListTransitions in GetTicket
- Distinguish "not found" from real errors in jiraSecretAdapter.Exists
- Log secret Exists errors instead of swallowing
- walkADF: handle softBreak (was only hardBreak despite the doc-comment)
- Consolidate JIRA_KEY_RE in jira-ticket-common; remove duplicates
- ticket-row imports formatRelative/statusBadgeClass from common
- isFilterState type guard validates SavedView.filters shape
- task-presets readStorage preserves user's "cleared all" intent (returns [])
- task-presets-section gates draft sync on `loaded` to avoid wiping in-progress edits
- jira-api: caller `init` no longer overrides method/body via spread order
- jira-settings: disable Test button when no saved/typed secret

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): record auth probe failure even when probe ctx expires

- RecordAuthHealth now uses a fresh context.Background-derived ctx with a
  5s timeout for the UpdateAuthHealth DB write, so a probe that consumes
  its full 15s deadline still persists the failure (greptile P1).
- SetConfig goroutine drops its redundant outer timeout — RecordAuthHealth
  manages its own probe timeout, so context.Background is enough.
- Add Service.SetProbeHook so tests can synchronize on probe completion;
  service_test waitForAuthProbe now blocks on the hook channel instead of
  time.Sleep polling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): address coderabbit review feedback (round 3)

- url.PathEscape ticketKey in GetTicket/ListTransitions/DoTransition paths
  to harden against malicious keys ('../../foo', 'KEY?override=x').
- factoryHit on the test fixture is now sync/atomic.Int32; the async probe
  goroutine spawned by SetConfig races the test thread under -race.
- GetConfig logs Reveal failures (was silently dropped, leaving the UI to
  show "no expiry info" indistinguishably from a freshly-saved cookie).
- DeleteConfig logs secret-delete errors instead of swallowing them.
- clientFor distinguishes a secret-store failure from "not configured" so
  the UI doesn't render the configure-CTA for transient backend errors.
- Unify ErrNotConfigured wire code: HTTP and WebSocket both return
  "JIRA_NOT_CONFIGURED" via a shared constant.
- JiraLinkButton strips a leading "PROJ-123: " before re-prefixing so
  re-linking a task replaces the prefix instead of stacking it.
- jira-settings clears testResult on save/delete so the alert reflects
  only the currently-saved credentials.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(jira): cover Reveal error path in clientFor

Reveal-error case is distinct from "no config row" — the former wraps
the underlying error, the latter returns ErrNotConfigured. Without
this test the changed contract has no regression coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): route secret adapter Set through Exists

Treating every Get error as "not found" turned a transient DB error on
an existing row into a constraint-violation Create that masked the real
cause. Reuse Exists so the "secret not found:" prefix is the only path
that falls through to Create.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(jira): cover jiraSecretAdapter.Set branching

Routing Set through Exists changed the contract: a transient DB error
in Get now propagates instead of cascading into a constraint-violation
Create. Add three tests so a future refactor cannot regress this without
CI catching it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): split Reveal errors and guard Save for new configs

- resolveCredentials now mirrors the clientFor split: a Reveal error
  surfaces as `read jira secret: %w` with a warn log instead of
  collapsing into "no token stored — paste one to test".
- disableSave gains the same missing-secret guard as disableTest so a
  brand-new config can no longer POST an empty secret and leave the
  workspace stuck with hasSecret=false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-04-25 22:47:45 +01:00
committed by GitHub
parent b04967bf70
commit 54b98ed9eb
52 changed files with 6994 additions and 19 deletions
+84
View File
@@ -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")
}
}
+6
View File
@@ -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)")
+12
View File
@@ -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)
}
+61
View File
@@ -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
+2
View File
@@ -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
}
+33
View File
@@ -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)
}
+443
View File
@@ -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")
}
}
}
@@ -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)
}
}
+354
View File
@@ -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})
}
}
+36
View File
@@ -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
}
+52
View File
@@ -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())
}
})
}
}
+120
View File
@@ -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"
}
+104
View File
@@ -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)
}
}
+208
View File
@@ -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)
}
}
+20
View File
@@ -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
}
+406
View File
@@ -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
}
+387
View File
@@ -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)
}
}
+177
View File
@@ -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, &notnull, &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
}
+174
View File
@@ -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)
}
}
+11
View File
@@ -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"
+337
View File
@@ -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 (
<div className="p-6 max-w-2xl">
<Alert>
<AlertDescription>
Jira is not configured for this workspace.{" "}
<Link
href={workspaceId ? `/settings/workspace/${workspaceId}/jira` : "/settings"}
className="underline font-medium"
>
Configure Jira
</Link>{" "}
to see your tickets here.
</AlertDescription>
</Alert>
</div>
);
}
async function loadUserProjects(workspaceId: string): Promise<JiraProject[]> {
const [{ projects: all }, search] = await Promise.all([
listJiraProjects(workspaceId),
searchJiraTickets(workspaceId, {
jql: "(assignee = currentUser() OR reporter = currentUser()) ORDER BY updated DESC",
maxResults: 100,
}),
]);
const userKeys = new Set(search.tickets.map((t) => t.projectKey));
return (all ?? []).filter((p) => userKeys.has(p.key));
}
function useJiraPageData(workspaceId?: string) {
const [loaded, setLoaded] = useState(false);
const [configured, setConfigured] = useState(false);
const [projects, setProjects] = useState<JiraProject[]>([]);
useEffect(() => {
let cancelled = false;
async function load() {
if (!workspaceId) {
setLoaded(true);
return;
}
try {
const cfg = await getJiraConfig(workspaceId);
if (cancelled) return;
const ok = !!cfg && cfg.hasSecret;
setConfigured(ok);
if (ok) {
try {
const list = await loadUserProjects(workspaceId);
if (!cancelled) setProjects(list);
} catch {
// Non-fatal: pill will just show empty list. Users can still filter by other dims.
}
}
} finally {
if (!cancelled) setLoaded(true);
}
}
void load();
return () => {
cancelled = true;
};
}, [workspaceId]);
return { loaded, configured, projects };
}
function TicketResults({
items,
loading,
error,
workspaceId,
presets,
onStartTask,
onOpen,
}: {
items: JiraTicket[];
loading: boolean;
error: string | null;
workspaceId: string | null | undefined;
presets: JiraTaskPreset[];
onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
onOpen: (ticket: JiraTicket) => void;
}) {
if (error) {
return (
<div className="flex justify-center py-16">
<JiraErrorMessage error={error} workspaceId={workspaceId} />
</div>
);
}
if (!loading && items.length === 0) {
return <div className="text-sm text-muted-foreground py-8 text-center">No tickets found.</div>;
}
return (
<div>
{items.map((t) => (
<TicketRow
key={t.key}
ticket={t}
presets={presets}
onStartTask={onStartTask}
onOpen={onOpen}
/>
))}
</div>
);
}
type AuthenticatedViewProps = {
workspaceId: string | undefined;
projects: JiraProject[];
presets: JiraTaskPreset[];
onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
onOpenTicket: (ticket: JiraTicket) => void;
};
function AuthenticatedView({
workspaceId,
projects,
presets,
onStartTask,
onOpenTicket,
}: AuthenticatedViewProps) {
const state = useFilterState();
const search = useJiraSearch(workspaceId ?? null, state.effectiveJql);
return (
<>
<ListToolbar
searchText={state.filters.searchText}
onSearchChange={(searchText) => state.updateFilters({ ...state.filters, searchText })}
views={state.views}
activeViewId={state.activeViewId}
onSelectView={state.selectView}
onDeleteView={state.deleteView}
onSaveView={state.saveCurrentAsView}
count={search.items.length}
loading={search.loading}
sort={state.filters.sort}
onSortChange={(sort) => state.updateFilters({ ...state.filters, sort })}
onRefresh={search.refresh}
showJqlEditor={state.showJqlEditor}
onToggleJqlEditor={() => state.setShowJqlEditor(!state.showJqlEditor)}
/>
{state.showJqlEditor && (
<JqlEditor
composedJql={state.composedJql}
customJql={state.customJql}
onApply={state.applyCustomJql}
onReset={state.resetCustomJql}
/>
)}
{state.customJql === null && (
<FilterBar
filters={state.filters}
onChange={state.updateFilters}
projects={projects}
hasActiveFilters={hasActiveFilters(state.filters)}
onClear={() => state.updateFilters(DEFAULT_FILTERS)}
/>
)}
<main className="flex-1 overflow-auto px-6 py-2">
<TicketResults
items={search.items}
loading={search.loading}
error={search.error}
workspaceId={workspaceId}
presets={presets}
onStartTask={onStartTask}
onOpen={onOpenTicket}
/>
</main>
<ResultsPagination
page={search.page}
pageSize={search.pageSize}
itemCount={search.items.length}
isLast={search.isLast}
onNext={search.goNext}
onPrev={search.goPrev}
/>
</>
);
}
function useFilterState() {
const savedViews = useSavedViews();
const [filters, setFilters] = useState<FilterState>(DEFAULT_VIEW.filters);
const [activeViewId, setActiveViewId] = useState<string | null>(DEFAULT_VIEW.id);
const [customJql, setCustomJql] = useState<string | null>(null);
const [showJqlEditor, setShowJqlEditor] = useState(false);
const composedJql = useMemo(() => filtersToJql(filters), [filters]);
const effectiveJql = customJql ?? composedJql;
const updateFilters = useCallback((next: FilterState) => {
setFilters(next);
setActiveViewId(null);
setCustomJql(null);
}, []);
const selectView = useCallback(
(id: string) => {
const v = savedViews.views.find((x) => x.id === id);
if (!v) return;
setFilters(v.filters);
setActiveViewId(id);
const savedCustomJql = v.customJql ?? null;
setCustomJql(savedCustomJql);
// Auto-open the JQL editor when restoring a JQL-backed view so the user
// can see what's running.
if (savedCustomJql !== null) setShowJqlEditor(true);
},
[savedViews.views],
);
const saveCurrentAsView = useCallback(
(name: string) => {
const view = savedViews.save(name, filters, customJql);
setActiveViewId(view.id);
},
[savedViews, filters, customJql],
);
const resetCustomJql = useCallback(() => setCustomJql(null), []);
return {
filters,
updateFilters,
views: savedViews.views,
activeViewId,
selectView,
deleteView: savedViews.remove,
saveCurrentAsView,
composedJql,
customJql,
effectiveJql,
applyCustomJql: setCustomJql,
resetCustomJql,
showJqlEditor,
setShowJqlEditor,
};
}
export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClientProps) {
const { loaded, configured, projects } = useJiraPageData(workspaceId);
const { taskPresets } = useJiraTaskPresets();
const [launchPayload, setLaunchPayload] = useState<JiraLaunchPayload | null>(null);
const [openTicket, setOpenTicket] = useState<JiraTicket | null>(null);
const onStartTask = useCallback((ticket: JiraTicket, preset: JiraTaskPreset) => {
setLaunchPayload({ ticket, preset });
}, []);
const onCloseLaunch = useCallback(() => setLaunchPayload(null), []);
const onOpenTicket = useCallback((ticket: JiraTicket) => setOpenTicket(ticket), []);
return (
<div className="h-screen w-full flex flex-col bg-background">
<PageTopbar
title="Jira"
subtitle="Tickets across your Atlassian projects."
icon={<IconTicket className="h-4 w-4" />}
/>
{!loaded && <div className="p-6 text-sm text-muted-foreground">Checking Jira status…</div>}
{loaded && !configured && <NotConfiguredNotice workspaceId={workspaceId} />}
{loaded && configured && (
<AuthenticatedView
workspaceId={workspaceId}
projects={projects}
presets={taskPresets}
onStartTask={onStartTask}
onOpenTicket={onOpenTicket}
/>
)}
<QuickTaskLauncher
workspaceId={workspaceId ?? null}
workflows={workflows}
steps={steps}
payload={launchPayload}
onClose={onCloseLaunch}
/>
<JiraTicketDialog
open={!!openTicket}
onOpenChange={(v) => {
if (!v) setOpenTicket(null);
}}
workspaceId={workspaceId}
ticketKey={openTicket?.key}
initialTicket={openTicket}
presets={taskPresets}
onStartTask={(ticket, preset) => {
setOpenTicket(null);
onStartTask(ticket, preset);
}}
/>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import {
listWorkspacesAction,
listWorkflowsAction,
listWorkspaceWorkflowStepsAction,
} from "@/app/actions/workspaces";
import { fetchUserSettings } from "@/lib/api";
import { StateHydrator } from "@/components/state-hydrator";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import { JiraPageClient } from "./jira-page-client";
import type { Workflow, WorkflowStep, Workspace, UserSettingsResponse } from "@/lib/types/http";
import type { AppState } from "@/lib/state/store";
export default async function JiraPage() {
let workspaces: Workspace[] = [];
let workflows: Workflow[] = [];
let steps: WorkflowStep[] = [];
let workspaceId: string | undefined;
let userSettingsResponse: UserSettingsResponse | null = null;
try {
const [workspacesResponse, settingsResponse] = await Promise.all([
listWorkspacesAction(),
fetchUserSettings({ cache: "no-store" }).catch(() => null),
]);
workspaces = workspacesResponse.workspaces;
userSettingsResponse = settingsResponse;
workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id;
if (workspaceId) {
const [workflowsRes, stepsRes] = await Promise.all([
listWorkflowsAction(workspaceId),
listWorkspaceWorkflowStepsAction(workspaceId),
]);
workflows = workflowsRes.workflows;
steps = stepsRes.steps;
}
} catch (error) {
console.error("Failed to load Jira page data:", error);
}
const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse);
const initialState: Partial<AppState> = {
workspaces: { items: workspaces, activeId: workspaceId ?? null },
workflows: {
items: workflows.map((w) => ({
id: w.id,
workspaceId: w.workspace_id,
name: w.name,
description: w.description ?? null,
sortOrder: w.sort_order ?? 0,
...(w.agent_profile_id ? { agent_profile_id: w.agent_profile_id } : {}),
})),
activeId: workflows[0]?.id ?? null,
},
userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null },
};
return (
<>
<StateHydrator initialState={initialState} />
<JiraPageClient workspaceId={workspaceId} workflows={workflows} steps={steps} />
</>
);
}
@@ -0,0 +1,11 @@
import { JiraSettings } from "@/components/jira/jira-settings";
import { StateProvider } from "@/components/state-provider";
export default async function WorkspaceJiraPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<StateProvider initialState={{}}>
<JiraSettings workspaceId={id} />
</StateProvider>
);
}
@@ -0,0 +1,109 @@
"use client";
import { useCallback, useState } from "react";
import { IconTicket } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { getJiraTicket } from "@/lib/api/domains/jira-api";
import type { JiraTicket } from "@/lib/types/jira";
import { JIRA_KEY_RE } from "./jira-ticket-common";
function extractKey(input: string): string | null {
const match = input.toUpperCase().match(JIRA_KEY_RE);
return match ? match[0] : null;
}
type JiraImportBarProps = {
workspaceId: string | null;
disabled?: boolean;
onImport: (ticket: JiraTicket) => void;
};
export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBarProps) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(async () => {
if (!workspaceId) {
setError("Select a workspace first");
return;
}
const key = extractKey(value.trim());
if (!key) {
setError("Paste a Jira ticket URL or key (PROJ-123)");
return;
}
setLoading(true);
setError(null);
try {
const ticket = await getJiraTicket(workspaceId, key);
onImport(ticket);
setOpen(false);
setValue("");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [workspaceId, value, onImport]);
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label="Import from Jira"
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-slate-400"
>
<IconTicket className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Import from Jira ticket URL or key</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-80 p-3">
<div className="space-y-2">
<div className="text-xs font-medium">Import Jira ticket</div>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="PROJ-123 or paste ticket URL"
className="h-8 text-xs"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void submit();
}
}}
/>
{error && (
<p className="text-[11px] text-destructive" role="alert">
{error}
</p>
)}
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={() => void submit()}
disabled={loading || !value.trim()}
className="h-7 cursor-pointer"
>
{loading ? "Loading..." : "Import"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,128 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { IconLink } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { toast } from "sonner";
import { getJiraConfig, getJiraTicket } from "@/lib/api/domains/jira-api";
import { updateTask } from "@/lib/api/domains/kanban-api";
import { JIRA_KEY_RE } from "./jira-ticket-common";
function useJiraConfigured(workspaceId: string | null | undefined): boolean {
const [configured, setConfigured] = useState(false);
useEffect(() => {
if (!workspaceId) return;
let cancelled = false;
void getJiraConfig(workspaceId)
.then((cfg) => {
if (!cancelled) setConfigured(!!cfg && cfg.hasSecret);
})
.catch(() => {
if (!cancelled) setConfigured(false);
});
return () => {
cancelled = true;
};
}, [workspaceId]);
return workspaceId ? configured : false;
}
type JiraLinkButtonProps = {
taskId: string | null | undefined;
workspaceId: string | null | undefined;
taskTitle: string | undefined | null;
};
// JiraLinkButton lets the user attach a Jira ticket to an existing task by
// prepending the ticket key to its title ("PROJ-123: ..."). The existing
// JiraTicketButton picks up the key automatically once the title is updated.
export function JiraLinkButton({ taskId, workspaceId, taskTitle }: JiraLinkButtonProps) {
const configured = useJiraConfigured(workspaceId);
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(async () => {
if (!taskId || !workspaceId) return;
const match = value.toUpperCase().match(JIRA_KEY_RE);
if (!match) {
setError("Paste a Jira ticket URL or key (PROJ-123)");
return;
}
const key = match[0];
setLoading(true);
setError(null);
try {
await getJiraTicket(workspaceId, key);
// Strip an existing leading "PROJ-123: " so re-linking a task to a
// different ticket replaces the prefix instead of stacking
// ("PROJ-456: PROJ-123: ...").
const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9]+-\d+:\s*/, "");
const newTitle = stripped ? `${key}: ${stripped}` : key;
await updateTask(taskId, { title: newTitle });
toast.success(`Linked to ${key}`);
setOpen(false);
setValue("");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [taskId, workspaceId, value, taskTitle]);
if (!configured || !taskId || !workspaceId) return null;
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer px-2 gap-1">
<IconLink className="h-4 w-4" />
<span className="text-xs font-medium">Link Jira</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Link this task to a Jira ticket</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-80 p-3">
<div className="space-y-2">
<div className="text-xs font-medium">Link to Jira ticket</div>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="PROJ-123 or paste ticket URL"
className="h-8 text-xs"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void submit();
}
}}
/>
{error && (
<p className="text-[11px] text-destructive" role="alert">
{error}
</p>
)}
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={() => void submit()}
disabled={loading || !value.trim()}
className="h-7 cursor-pointer"
>
{loading ? "Linking..." : "Link"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
+567
View File
@@ -0,0 +1,567 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { IconTicket, IconCode, IconCheck, IconAlertTriangle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Separator } from "@kandev/ui/separator";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Switch } from "@kandev/ui/switch";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { TaskPresetsSection } from "@/components/jira/task-presets-section";
import { useJiraEnabled } from "@/components/jira/my-jira/use-jira-enabled";
import {
getJiraConfig,
setJiraConfig,
deleteJiraConfig,
testJiraConnection,
} from "@/lib/api/domains/jira-api";
import type { JiraAuthMethod, JiraConfig, TestJiraConnectionResult } from "@/lib/types/jira";
// How often to re-fetch the config so the auth-health indicator picks up new
// probe results from the backend poller (which runs every 90s).
const STATUS_REFRESH_MS = 90_000;
// Session cookies are HttpOnly so document.cookie can't read them, but
// DevTools → Application → Cookies surfaces them in plain text. Users copy
// the Value cell of a single row; the backend wraps it under both
// cloud.session.token and tenant.session.token so a single paste works for
// password accounts and SSO tenants.
const COOKIE_INSTRUCTIONS = `Open DevTools (Cmd+Opt+I / Ctrl+Shift+I) on your Atlassian tab →
Application tab → Storage → Cookies → https://*.atlassian.net →
find the row named "cloud.session.token" (or "tenant.session.token"
on SSO tenants) → copy the Value cell → paste it below.
Don't include the cookie name or any "=" — just the token value.`;
type FormState = {
siteUrl: string;
email: string;
authMethod: JiraAuthMethod;
defaultProjectKey: string;
secret: string;
};
const emptyForm: FormState = {
siteUrl: "",
email: "",
authMethod: "api_token",
defaultProjectKey: "",
secret: "",
};
function configToForm(cfg: JiraConfig | null): FormState {
if (!cfg) return emptyForm;
return {
siteUrl: cfg.siteUrl,
email: cfg.email,
authMethod: cfg.authMethod,
defaultProjectKey: cfg.defaultProjectKey,
secret: "",
};
}
function saveLabel(saving: boolean, hasConfig: boolean): string {
if (saving) return "Saving...";
return hasConfig ? "Update" : "Save";
}
type FieldsRowProps = {
form: FormState;
loading: boolean;
update: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
};
function SiteFields({ form, loading, update }: FieldsRowProps) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="jira-site">Site URL</Label>
<Input
id="jira-site"
placeholder="https://acme.atlassian.net"
value={form.siteUrl}
onChange={(e) => update("siteUrl", e.target.value)}
disabled={loading}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="jira-project">Default project key (optional)</Label>
<Input
id="jira-project"
placeholder="PROJ"
value={form.defaultProjectKey}
onChange={(e) => update("defaultProjectKey", e.target.value.toUpperCase())}
disabled={loading}
/>
</div>
</div>
);
}
function AuthFields({ form, loading, update }: FieldsRowProps) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="jira-auth">Authentication method</Label>
<Select
value={form.authMethod}
onValueChange={(v) => update("authMethod", v as JiraAuthMethod)}
disabled={loading}
>
<SelectTrigger id="jira-auth" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="api_token">API token (recommended)</SelectItem>
<SelectItem value="session_cookie">Browser session cookie</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="jira-email">
Email {form.authMethod === "session_cookie" && "(optional)"}
</Label>
<Input
id="jira-email"
type="email"
placeholder="you@example.com"
value={form.email}
onChange={(e) => update("email", e.target.value)}
disabled={loading}
/>
</div>
</div>
);
}
function SessionSnippet() {
const [show, setShow] = useState(false);
return (
<div className="text-xs text-muted-foreground space-y-2">
<button
type="button"
onClick={() => setShow((v) => !v)}
className="inline-flex items-center gap-1 underline cursor-pointer"
>
<IconCode className="h-3 w-3" />
{show ? "Hide" : "Show"} how to copy the session token
</button>
{show && (
<pre className="bg-muted rounded p-3 text-[11px] overflow-x-auto whitespace-pre-wrap">
<code>{COOKIE_INSTRUCTIONS}</code>
</pre>
)}
</div>
);
}
type SecretFieldProps = FieldsRowProps & { hasSavedSecret: boolean };
function secretPlaceholder(isApiToken: boolean, hasSavedSecret: boolean): string {
if (hasSavedSecret) return "••••••••";
return isApiToken ? "paste token here" : "paste cloud.session.token value";
}
function formatExpiry(expiresAt: string): { label: string; tone: "ok" | "warn" | "danger" } {
const diffMs = new Date(expiresAt).getTime() - Date.now();
if (Number.isNaN(diffMs)) return { label: "Expiry unknown", tone: "warn" };
if (diffMs <= 0) return { label: "Cookie expired — paste a fresh one", tone: "danger" };
const hours = diffMs / (60 * 60 * 1000);
if (hours < 24) {
const h = Math.max(1, Math.round(hours));
return { label: `Cookie expires in ${h}h`, tone: "danger" };
}
const days = Math.round(hours / 24);
return {
label: `Cookie expires in ${days} day${days === 1 ? "" : "s"}`,
tone: days < 7 ? "warn" : "ok",
};
}
const TONE_CLASSES: Record<"ok" | "warn" | "danger", string> = {
ok: "text-muted-foreground",
warn: "text-amber-600 dark:text-amber-400",
danger: "text-destructive",
};
function CookieExpiry({ expiresAt }: { expiresAt: string }) {
const { label, tone } = formatExpiry(expiresAt);
const absolute = new Date(expiresAt).toLocaleString();
return (
<p className={`text-xs ${TONE_CLASSES[tone]}`} title={absolute}>
{label}
</p>
);
}
type SecretFieldPropsWithExpiry = SecretFieldProps & { secretExpiresAt?: string | null };
function SecretField({
form,
loading,
update,
hasSavedSecret,
secretExpiresAt,
}: SecretFieldPropsWithExpiry) {
const isApiToken = form.authMethod === "api_token";
return (
<div className="space-y-1.5">
<Label htmlFor="jira-secret">
{isApiToken ? "API token" : "Session token value"}
{hasSavedSecret && (
<span className="text-xs text-muted-foreground ml-2">
(saved — leave blank to keep the current value)
</span>
)}
</Label>
<Input
id="jira-secret"
type="password"
placeholder={secretPlaceholder(isApiToken, hasSavedSecret)}
value={form.secret}
onChange={(e) => update("secret", e.target.value)}
disabled={loading}
/>
{!isApiToken && hasSavedSecret && secretExpiresAt && (
<CookieExpiry expiresAt={secretExpiresAt} />
)}
{isApiToken && (
<p className="text-xs text-muted-foreground">
Create a token at{" "}
<a
className="underline"
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noreferrer"
>
id.atlassian.com/manage-profile/security/api-tokens
</a>
</p>
)}
{!isApiToken && <SessionSnippet />}
</div>
);
}
function TestResultAlert({ result }: { result: TestJiraConnectionResult | null }) {
if (!result) return null;
return (
<Alert variant={result.ok ? "default" : "destructive"}>
<AlertDescription>
{result.ok
? `Connected as ${result.displayName || result.email || result.accountId}`
: `Failed: ${result.error}`}
</AlertDescription>
</Alert>
);
}
type AuthHealth = {
ok: boolean;
error: string;
checkedAt: Date | null;
};
function configToHealth(config: JiraConfig | null): AuthHealth | null {
if (!config?.hasSecret) return null;
if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null };
return {
ok: !!config.lastOk,
error: config.lastError ?? "",
checkedAt: new Date(config.lastCheckedAt),
};
}
// Re-render every 30s so "checked 1 minute ago" doesn't sit stale on a long-
// open settings tab.
function useTick(intervalMs: number) {
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
}
function LastCheckedLabel({ checkedAt }: { checkedAt: Date | null }) {
useTick(30_000);
if (!checkedAt) return null;
return (
<span className="text-xs text-muted-foreground ml-2">
· checked {formatDistanceToNow(checkedAt, { addSuffix: true })}
</span>
);
}
function AuthStatusBanner({ health }: { health: AuthHealth | null }) {
if (!health) return null;
if (!health.checkedAt) {
return (
<Alert>
<AlertDescription className="text-sm">
Waiting for the next backend health check…
</AlertDescription>
</Alert>
);
}
if (health.ok) {
return (
<Alert className="border-green-500/40 bg-green-500/10 dark:border-green-400/30 dark:bg-green-400/10">
<IconCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertDescription className="text-sm font-medium">
Authenticated
<LastCheckedLabel checkedAt={health.checkedAt} />
</AlertDescription>
</Alert>
);
}
return (
<Alert variant="destructive">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
Authentication failed: {health.error || "unknown error"}
<LastCheckedLabel checkedAt={health.checkedAt} />
</AlertDescription>
</Alert>
);
}
type ActionBarProps = {
saving: boolean;
testing: boolean;
loading: boolean;
hasConfig: boolean;
disableSave: boolean;
disableTest: boolean;
onTest: () => void;
onSave: () => void;
onDelete: () => void;
};
function ActionBar({
saving,
testing,
loading,
hasConfig,
disableSave,
disableTest,
onTest,
onSave,
onDelete,
}: ActionBarProps) {
return (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={onTest}
disabled={testing || loading || disableTest}
className="cursor-pointer"
title={disableTest ? "Paste a token to test the connection" : undefined}
>
{testing ? "Testing..." : "Test connection"}
</Button>
<Button type="button" onClick={onSave} disabled={disableSave} className="cursor-pointer">
{saveLabel(saving, hasConfig)}
</Button>
{hasConfig && (
<Button
type="button"
variant="destructive"
onClick={onDelete}
className="ml-auto cursor-pointer"
>
Remove configuration
</Button>
)}
</div>
);
}
type JiraSettingsProps = {
workspaceId: string;
};
function useJiraSettings(workspaceId: string) {
const { toast } = useToast();
const [config, setConfig] = useState<JiraConfig | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestJiraConnectionResult | null>(null);
const health = configToHealth(config);
const load = useCallback(async () => {
setLoading(true);
try {
const cfg = await getJiraConfig(workspaceId);
setConfig(cfg);
setForm(configToForm(cfg));
} catch (err) {
toast({ description: `Failed to load Jira config: ${String(err)}`, variant: "error" });
} finally {
setLoading(false);
}
}, [workspaceId, toast]);
useEffect(() => {
void load();
}, [load]);
// Background refresh so the auth-health banner picks up new probe results
// from the backend poller without requiring a page reload. We re-fetch the
// config rather than the loud full `load()` to avoid flashing the form.
useEffect(() => {
const id = setInterval(() => {
getJiraConfig(workspaceId)
.then((cfg) => setConfig(cfg))
.catch(() => {
/* transient failures are fine — next tick retries */
});
}, STATUS_REFRESH_MS);
return () => clearInterval(id);
}, [workspaceId]);
const update = useCallback(
<K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value })),
[],
);
const handleTest = useCallback(async () => {
setTesting(true);
setTestResult(null);
try {
const res = await testJiraConnection({ workspaceId, ...form });
setTestResult(res);
} catch (err) {
setTestResult({ ok: false, error: String(err) });
} finally {
setTesting(false);
}
}, [workspaceId, form]);
const handleSave = useCallback(async () => {
setSaving(true);
try {
const saved = await setJiraConfig({
workspaceId,
siteUrl: form.siteUrl,
email: form.email,
authMethod: form.authMethod,
defaultProjectKey: form.defaultProjectKey,
secret: form.secret || undefined,
});
setConfig(saved);
setForm(configToForm(saved));
// Clear any inline test result from the previous credentials so the
// alert reflects only the currently-saved state.
setTestResult(null);
toast({ description: "Jira configuration saved", variant: "success" });
} catch (err) {
toast({ description: `Save failed: ${String(err)}`, variant: "error" });
} finally {
setSaving(false);
}
}, [workspaceId, form, toast]);
const handleDelete = useCallback(async () => {
if (!confirm("Remove Jira configuration for this workspace?")) return;
try {
await deleteJiraConfig(workspaceId);
setConfig(null);
setForm(emptyForm);
setTestResult(null);
toast({ description: "Jira configuration removed", variant: "success" });
} catch (err) {
toast({ description: `Delete failed: ${String(err)}`, variant: "error" });
}
}, [workspaceId, toast]);
return {
config,
form,
loading,
saving,
testing,
testResult,
health,
update,
handleTest,
handleSave,
handleDelete,
};
}
function EnabledPill({ workspaceId }: { workspaceId: string }) {
const { enabled, setEnabled } = useJiraEnabled(workspaceId);
return (
<div className="flex items-center gap-2 rounded-full border bg-muted/30 px-3 py-1">
<Switch
id="jira-enabled"
checked={enabled}
onCheckedChange={setEnabled}
className="cursor-pointer"
/>
<Label htmlFor="jira-enabled" className="text-xs cursor-pointer">
{enabled ? "Enabled" : "Disabled"}
</Label>
</div>
);
}
export function JiraSettings({ workspaceId }: JiraSettingsProps) {
const s = useJiraSettings(workspaceId);
const missingSecret = !s.config?.hasSecret && !s.form.secret;
const disableSave =
s.saving ||
!s.form.siteUrl ||
(s.form.authMethod === "api_token" && !s.form.email) ||
missingSecret;
const disableTest = missingSecret;
return (
<div className="space-y-8">
<SettingsSection
icon={<IconTicket className="h-5 w-5" />}
title="Jira integration"
description="Connect this workspace to an Atlassian Cloud site. Credentials are stored encrypted server-side."
action={<EnabledPill workspaceId={workspaceId} />}
>
<Card>
<CardContent className="space-y-4 pt-6">
<AuthStatusBanner health={s.health} />
<SiteFields form={s.form} loading={s.loading} update={s.update} />
<AuthFields form={s.form} loading={s.loading} update={s.update} />
<SecretField
form={s.form}
loading={s.loading}
update={s.update}
hasSavedSecret={!!s.config?.hasSecret}
secretExpiresAt={s.config?.secretExpiresAt ?? null}
/>
<TestResultAlert result={s.testResult} />
<Separator />
<ActionBar
saving={s.saving}
testing={s.testing}
loading={s.loading}
hasConfig={!!s.config}
disableSave={disableSave}
disableTest={disableTest}
onTest={s.handleTest}
onSave={s.handleSave}
onDelete={s.handleDelete}
/>
</CardContent>
</Card>
</SettingsSection>
<TaskPresetsSection />
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { IconTicket } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { JiraTicketDialog } from "./jira-ticket-dialog";
import { extractJiraKey } from "./jira-ticket-common";
export { extractJiraKey };
type JiraTicketButtonProps = {
workspaceId: string | null | undefined;
taskTitle: string | undefined | null;
};
// JiraTicketButton sits in the task top bar. It extracts a Jira key from the
// task title and opens a full ticket dialog on click.
export function JiraTicketButton({ workspaceId, taskTitle }: JiraTicketButtonProps) {
const ticketKey = extractJiraKey(taskTitle);
const [open, setOpen] = useState(false);
if (!workspaceId || !ticketKey) return null;
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="cursor-pointer px-2 gap-1"
onClick={() => setOpen(true)}
>
<IconTicket className="h-4 w-4" />
<span className="text-xs font-medium">{ticketKey}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Open Jira ticket {ticketKey}</TooltipContent>
</Tooltip>
<JiraTicketDialog
open={open}
onOpenChange={setOpen}
workspaceId={workspaceId}
ticketKey={ticketKey}
/>
</>
);
}
@@ -0,0 +1,212 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { IconLockExclamation } from "@tabler/icons-react";
import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar";
import { Button } from "@kandev/ui/button";
import { getJiraTicket, transitionJiraTicket } from "@/lib/api/domains/jira-api";
import type { JiraStatusCategory, JiraTicket } from "@/lib/types/jira";
// Matches PROJECT-123 anywhere in the string. Jira keys start with letters and
// include an uppercase prefix, followed by a dash and one or more digits.
export const JIRA_KEY_RE = /\b[A-Z][A-Z0-9]+-\d+\b/;
export function extractJiraKey(title: string | undefined | null): string | null {
if (!title) return null;
const match = title.match(JIRA_KEY_RE);
return match ? match[0] : null;
}
// Map Jira's statusCategory to Tailwind color classes. Jira returns one of
// three stable keys: "new" (to-do), "indeterminate" (in-progress), "done".
export function statusBadgeClass(category: JiraStatusCategory | undefined): string {
switch (category) {
case "done":
return "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30";
case "indeterminate":
return "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30";
case "new":
return "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30";
default:
return "";
}
}
export function formatRelative(iso: string | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return formatDistanceToNow(d, { addSuffix: true });
}
export function PersonCell({ name, avatar }: { name?: string; avatar?: string }) {
if (!name) return <span className="text-muted-foreground">Unassigned</span>;
return (
<>
<Avatar size="sm" className="size-5">
{avatar && <AvatarImage src={avatar} alt={name} />}
<AvatarFallback className="text-[10px]">{name.charAt(0)}</AvatarFallback>
</Avatar>
<span className="truncate">{name}</span>
</>
);
}
export function IconLabel({ icon, label }: { icon?: string; label?: string }) {
if (!label) return <span className="text-muted-foreground">—</span>;
return (
<>
{icon && (
// Jira serves issuetype/priority icons from its CDN at fixed 16x16;
// next/image would require per-host allowlisting for no real gain.
// eslint-disable-next-line @next/next/no-img-element
<img src={icon} alt="" className="h-3.5 w-3.5" />
)}
<span className="truncate">{label}</span>
</>
);
}
export type TicketState = {
ticket: JiraTicket | null;
loading: boolean;
error: string | null;
pendingTransition: string | null;
load: () => Promise<void>;
handleTransition: (id: string) => Promise<void>;
};
// Shared hook for loading a Jira ticket and applying transitions. Consumers
// pass `enabled` so the fetch only kicks off when the UI (popover/dialog) is
// visible. Re-runs `load` when workspace/ticket key change.
export function useTicketState(
workspaceId: string,
ticketKey: string,
enabled: boolean,
): TicketState {
const [ticket, setTicket] = useState<JiraTicket | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingTransition, setPendingTransition] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const t = await getJiraTicket(workspaceId, ticketKey);
setTicket(t);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [workspaceId, ticketKey]);
useEffect(() => {
if (!enabled || !workspaceId || !ticketKey) return;
let cancelled = false;
async function run() {
setTicket(null);
setLoading(true);
setError(null);
try {
const t = await getJiraTicket(workspaceId, ticketKey);
if (!cancelled) setTicket(t);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
} finally {
if (!cancelled) setLoading(false);
}
}
void run();
return () => {
cancelled = true;
};
}, [enabled, workspaceId, ticketKey]);
const handleTransition = useCallback(
async (transitionId: string) => {
setPendingTransition(transitionId);
setError(null);
try {
await transitionJiraTicket(workspaceId, ticketKey, transitionId);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPendingTransition(null);
}
},
[workspaceId, ticketKey, load],
);
return { ticket, loading, error, pendingTransition, load, handleTransition };
}
// Backend wraps Jira responses as `jira api: status N: …`. 401/403 mean the
// session is invalid; 303 with "Step-up" means Atlassian wants re-auth.
const AUTH_STATUS_RE = /\bstatus (?:303|401|403)\b/i;
const STEP_UP_RE = /step-?up authentication/i;
export function isJiraAuthError(error: string): boolean {
return AUTH_STATUS_RE.test(error) || STEP_UP_RE.test(error);
}
// Drops support URLs Atlassian inlines into 3xx response bodies — they're
// noise once the user has a clear CTA.
const URL_RE = /\bhttps?:\/\/\S+/g;
export function cleanJiraErrorMessage(error: string): string {
return error.replace(URL_RE, "").replace(/\s+/g, " ").trim();
}
type JiraErrorMessageProps = {
error: string;
workspaceId?: string | null;
/** Inline variant for cases where a ticket is already rendered above. */
compact?: boolean;
};
export function JiraErrorMessage({ error, workspaceId, compact }: JiraErrorMessageProps) {
const isAuth = isJiraAuthError(error);
const settingsHref = workspaceId ? `/settings/workspace/${workspaceId}/jira` : "/settings";
if (compact) {
return (
<div className="flex items-center gap-3 text-sm">
<span className={isAuth ? "text-muted-foreground" : "text-destructive"}>
{isAuth ? "Jira authentication required." : cleanJiraErrorMessage(error)}
</span>
{isAuth && (
<Button asChild size="sm" variant="outline" className="cursor-pointer h-7 text-xs">
<Link href={settingsHref}>Reconnect Jira</Link>
</Button>
)}
</div>
);
}
return (
<div className="max-w-md text-center space-y-4">
{isAuth ? (
<>
<IconLockExclamation className="h-10 w-10 mx-auto text-muted-foreground" />
<div className="space-y-1.5">
<h2 className="text-lg font-semibold">Jira authentication required</h2>
<p className="text-sm text-muted-foreground">
Your Jira session expired or needs step-up authentication. Reconnect to view this
ticket.
</p>
</div>
<Button asChild size="sm" className="cursor-pointer">
<Link href={settingsHref}>Reconnect Jira</Link>
</Button>
</>
) : (
<p className="text-sm text-destructive">{cleanJiraErrorMessage(error)}</p>
)}
</div>
);
}
@@ -0,0 +1,347 @@
"use client";
import { IconExternalLink, IconRefresh, IconPlus } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import type { JiraTicket, JiraTransition } from "@/lib/types/jira";
import {
IconLabel,
JiraErrorMessage,
PersonCell,
formatRelative,
statusBadgeClass,
useTicketState,
type TicketState,
} from "./jira-ticket-common";
import type { JiraTaskPreset } from "./my-jira/presets";
type JiraTicketDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string | null | undefined;
ticketKey: string | null | undefined;
/** Shown while the full ticket is loading so the dialog doesn't flash empty. */
initialTicket?: JiraTicket | null;
/** Presets shown in the footer "Start task" menu. Required when onStartTask is set. */
presets?: JiraTaskPreset[];
/** If provided, shows a "Start task" menu in the dialog footer. */
onStartTask?: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
};
function dialogTitle(ticket: JiraTicket | null, ticketKey: string | null | undefined): string {
return ticket?.summary ?? ticketKey ?? "Jira ticket";
}
function effectiveTicketKey(
ticket: JiraTicket | null,
ticketKey: string | null | undefined,
): string {
return ticketKey ?? ticket?.key ?? "";
}
export function JiraTicketDialog({
open,
onOpenChange,
workspaceId,
ticketKey,
initialTicket,
presets,
onStartTask,
}: JiraTicketDialogProps) {
const enabled = open && !!workspaceId && !!ticketKey;
const state = useTicketState(workspaceId ?? "", ticketKey ?? "", enabled);
const ticket = state.ticket ?? initialTicket ?? null;
const showFooter = ticket !== null && onStartTask !== undefined && presets !== undefined;
const errorOnly = state.error !== null && ticket === null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[min(1280px,95vw)] w-[95vw] max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 sm:rounded-lg">
<DialogTitle className="sr-only">{dialogTitle(ticket, ticketKey)}</DialogTitle>
{errorOnly ? (
<div className="flex-1 flex items-center justify-center px-8 py-16">
<JiraErrorMessage error={state.error ?? ""} workspaceId={workspaceId} />
</div>
) : (
<>
<TicketTopBar
ticket={ticket}
loading={state.loading}
onRefresh={() => void state.load()}
/>
<DialogBody
ticket={ticket}
state={state}
ticketKey={effectiveTicketKey(ticket, ticketKey)}
workspaceId={workspaceId}
/>
{showFooter && (
<TicketFooter ticket={ticket} presets={presets} onStartTask={onStartTask} />
)}
</>
)}
</DialogContent>
</Dialog>
);
}
function DialogBody({
ticket,
state,
ticketKey,
workspaceId,
}: {
ticket: JiraTicket | null;
state: TicketState;
ticketKey: string;
workspaceId: string | null | undefined;
}) {
return (
<div className="flex-1 overflow-y-auto">
{state.error && ticket && (
<div className="px-8 pt-4">
<JiraErrorMessage error={state.error} workspaceId={workspaceId} compact />
</div>
)}
{!ticket && state.loading && (
<div className="text-sm text-muted-foreground py-16 text-center">Loading ticket…</div>
)}
{ticket && <TicketBody ticket={ticket} state={state} ticketKey={ticketKey} />}
</div>
);
}
function TicketFooter({
ticket,
presets,
onStartTask,
}: {
ticket: JiraTicket;
presets: JiraTaskPreset[];
onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
}) {
return (
<div className="flex items-center justify-end gap-2 px-6 py-3 border-t bg-muted/20 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="default" className="cursor-pointer gap-1.5">
<IconPlus className="h-4 w-4" />
Start task
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
{presets.map((p) => {
const Icon = p.icon;
return (
<DropdownMenuItem
key={p.id}
onClick={() => onStartTask(ticket, p)}
className="cursor-pointer"
>
<Icon className="h-4 w-4 mr-2" />
<div className="flex flex-col">
<span>{p.label}</span>
<span className="text-[11px] text-muted-foreground">{p.hint}</span>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
type TopBarProps = {
ticket: JiraTicket | null;
loading: boolean;
onRefresh: () => void;
};
function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) {
return (
<div className="flex items-center justify-end gap-1 pl-4 pr-12 py-2 border-b shrink-0">
{ticket?.url && (
<Button
asChild
variant="ghost"
size="icon-sm"
className="cursor-pointer"
title="Open in Atlassian"
>
<a href={ticket.url} target="_blank" rel="noreferrer">
<IconExternalLink className="h-4 w-4" />
</a>
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
className="cursor-pointer"
onClick={onRefresh}
disabled={loading}
title="Refresh"
>
<IconRefresh className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
);
}
function TicketBody({
ticket,
state,
ticketKey,
}: {
ticket: JiraTicket;
state: TicketState;
ticketKey: string;
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-8 px-8 py-6">
<div className="min-w-0 space-y-6">
<TicketHeading ticket={ticket} ticketKey={ticketKey} />
<DescriptionSection description={ticket.description} />
</div>
<aside className="space-y-4">
<StatusBlock
ticket={ticket}
transitions={ticket.transitions}
pending={state.pendingTransition}
onTransition={(id) => void state.handleTransition(id)}
/>
<DetailsCard ticket={ticket} />
<MetaFooter ticket={ticket} />
</aside>
</div>
);
}
function TicketHeading({ ticket, ticketKey }: { ticket: JiraTicket; ticketKey: string }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground font-mono">
<span>{ticketKey}</span>
{ticket.issueType && (
<>
<span>·</span>
<IconLabel icon={ticket.issueTypeIcon} label={ticket.issueType} />
</>
)}
</div>
<h2 className="text-2xl font-semibold leading-tight">{ticket.summary}</h2>
</div>
);
}
function SectionHeading({ children }: { children: React.ReactNode }) {
return <div className="text-sm font-semibold">{children}</div>;
}
function DescriptionSection({ description }: { description: string }) {
return (
<section className="space-y-2">
<SectionHeading>Description</SectionHeading>
<div className="rounded-md border bg-muted/30 px-4 py-3">
{description ? (
<div className="text-sm whitespace-pre-wrap leading-relaxed">{description}</div>
) : (
<div className="text-sm text-muted-foreground italic">No description.</div>
)}
</div>
</section>
);
}
type StatusBlockProps = {
ticket: JiraTicket;
transitions: JiraTransition[] | null | undefined;
pending: string | null;
onTransition: (id: string) => void;
};
function StatusBlock({ ticket, transitions, pending, onTransition }: StatusBlockProps) {
const list = transitions ?? [];
const hasTransitions = list.length > 0;
return (
<section className="space-y-2">
{ticket.statusName && (
<div>
<Badge
variant="outline"
className={`text-sm px-3 py-1 ${statusBadgeClass(ticket.statusCategory)}`}
>
{ticket.statusName}
</Badge>
</div>
)}
{hasTransitions && (
<div className="flex flex-wrap gap-1.5 pt-1">
{list.map((t) => (
<Button
key={t.id}
variant="outline"
size="sm"
className="cursor-pointer h-7 text-xs"
disabled={pending !== null}
onClick={() => onTransition(t.id)}
>
{pending === t.id ? "…" : `→ ${t.name}`}
</Button>
))}
</div>
)}
</section>
);
}
function DetailsCard({ ticket }: { ticket: JiraTicket }) {
return (
<section className="rounded-md border bg-background overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/30">
<SectionHeading>Details</SectionHeading>
</div>
<div className="px-4 py-3 space-y-3 text-sm">
<DetailRow label="Assignee">
<PersonCell name={ticket.assigneeName} avatar={ticket.assigneeAvatar} />
</DetailRow>
<DetailRow label="Reporter">
<PersonCell name={ticket.reporterName} avatar={ticket.reporterAvatar} />
</DetailRow>
<DetailRow label="Priority">
<IconLabel icon={ticket.priorityIcon} label={ticket.priority} />
</DetailRow>
<DetailRow label="Project">
<span className="font-mono text-xs">{ticket.projectKey}</span>
</DetailRow>
</div>
</section>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3">
<span className="text-muted-foreground text-xs w-20 shrink-0 pt-0.5">{label}</span>
<div className="min-w-0 flex items-center gap-1.5 flex-1">{children}</div>
</div>
);
}
function MetaFooter({ ticket }: { ticket: JiraTicket }) {
const updated = formatRelative(ticket.updated);
if (!updated) return null;
return (
<div className="text-xs text-muted-foreground px-1" title={ticket.updated}>
Updated {updated}
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import { Button } from "@kandev/ui/button";
import type { JiraProject } from "@/lib/types/jira";
import { AssigneePill, ProjectPill, StatusPill } from "./filter-pills";
import type { FilterState } from "./filter-model";
import { DEFAULT_FILTERS } from "./filter-model";
type FilterBarProps = {
filters: FilterState;
onChange: (filters: FilterState) => void;
projects: JiraProject[];
hasActiveFilters: boolean;
onClear: () => void;
};
export function FilterBar({
filters,
onChange,
projects,
hasActiveFilters,
onClear,
}: FilterBarProps) {
return (
<div className="flex items-center gap-2 flex-wrap px-6 py-2.5 border-b shrink-0 bg-muted/20">
<ProjectPill
projects={projects}
value={filters.projectKeys}
onChange={(projectKeys) => onChange({ ...filters, projectKeys })}
/>
<StatusPill
value={filters.statusCategories}
onChange={(statusCategories) => onChange({ ...filters, statusCategories })}
/>
<AssigneePill
value={filters.assignee}
onChange={(assignee) => onChange({ ...filters, assignee })}
/>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={onClear}
className="cursor-pointer h-7 text-xs ml-1"
>
Clear filters
</Button>
)}
</div>
);
}
export function hasActiveFilters(f: FilterState): boolean {
return (
f.projectKeys.length > 0 ||
f.statusCategories.length > 0 ||
f.assignee !== DEFAULT_FILTERS.assignee ||
f.searchText.trim().length > 0
);
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_FILTERS, filtersToJql, filtersEqual } from "./filter-model";
describe("filtersToJql", () => {
it("produces default query for default state", () => {
expect(filtersToJql(DEFAULT_FILTERS)).toBe("assignee = currentUser() ORDER BY updated DESC");
});
it("omits where clause when everything is open", () => {
expect(filtersToJql({ ...DEFAULT_FILTERS, assignee: "anyone" })).toBe("ORDER BY updated DESC");
});
it("quotes project keys", () => {
const jql = filtersToJql({ ...DEFAULT_FILTERS, projectKeys: ["SC", "ECOM"] });
expect(jql).toBe(
`project in ("SC", "ECOM") AND assignee = currentUser() ORDER BY updated DESC`,
);
});
it("maps status categories to Jira labels", () => {
const jql = filtersToJql({
...DEFAULT_FILTERS,
statusCategories: ["new", "indeterminate"],
assignee: "anyone",
});
expect(jql).toBe(`statusCategory in ("To Do", "In Progress") ORDER BY updated DESC`);
});
it("detects ticket key in search text", () => {
const jql = filtersToJql({ ...DEFAULT_FILTERS, searchText: "SC-123", assignee: "anyone" });
expect(jql).toBe(`key = "SC-123" ORDER BY updated DESC`);
});
it("uses text search for free-form query", () => {
const jql = filtersToJql({
...DEFAULT_FILTERS,
searchText: "login bug",
assignee: "anyone",
});
expect(jql).toBe(`text ~ "login bug" ORDER BY updated DESC`);
});
it("escapes quotes in search text", () => {
const jql = filtersToJql({
...DEFAULT_FILTERS,
searchText: `"bad" query`,
assignee: "anyone",
});
expect(jql).toBe(`text ~ "\\"bad\\" query" ORDER BY updated DESC`);
});
it("renders unassigned filter", () => {
expect(filtersToJql({ ...DEFAULT_FILTERS, assignee: "unassigned" })).toBe(
"assignee is EMPTY ORDER BY updated DESC",
);
});
it("switches sort clause", () => {
expect(filtersToJql({ ...DEFAULT_FILTERS, sort: "priority", assignee: "anyone" })).toBe(
"ORDER BY priority DESC, updated DESC",
);
expect(filtersToJql({ ...DEFAULT_FILTERS, sort: "created", assignee: "anyone" })).toBe(
"ORDER BY created DESC",
);
});
});
describe("filtersEqual", () => {
it("returns true for deep-equal state", () => {
expect(filtersEqual(DEFAULT_FILTERS, { ...DEFAULT_FILTERS })).toBe(true);
});
it("returns false when arrays differ", () => {
expect(filtersEqual(DEFAULT_FILTERS, { ...DEFAULT_FILTERS, projectKeys: ["X"] })).toBe(false);
});
});
@@ -0,0 +1,90 @@
import type { JiraStatusCategory } from "@/lib/types/jira";
export type AssigneeFilter = "me" | "unassigned" | "anyone";
export type SortKey = "updated" | "created" | "priority";
export type FilterState = {
projectKeys: string[];
statusCategories: JiraStatusCategory[];
assignee: AssigneeFilter;
searchText: string;
sort: SortKey;
};
export const DEFAULT_FILTERS: FilterState = {
projectKeys: [],
statusCategories: [],
assignee: "me",
searchText: "",
sort: "updated",
};
export const STATUS_CATEGORY_OPTIONS: { value: JiraStatusCategory; label: string }[] = [
{ value: "new", label: "To Do" },
{ value: "indeterminate", label: "In Progress" },
{ value: "done", label: "Done" },
];
function quote(value: string): string {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
const TICKET_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
function searchClause(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (TICKET_KEY_RE.test(trimmed)) return `key = ${quote(trimmed)}`;
return `text ~ ${quote(trimmed)}`;
}
function statusClause(categories: JiraStatusCategory[]): string | null {
if (categories.length === 0) return null;
const labels = categories.map((c) => {
if (c === "new") return quote("To Do");
if (c === "indeterminate") return quote("In Progress");
return quote("Done");
});
return `statusCategory in (${labels.join(", ")})`;
}
function assigneeClause(a: AssigneeFilter): string | null {
if (a === "me") return "assignee = currentUser()";
if (a === "unassigned") return "assignee is EMPTY";
return null;
}
function projectClause(keys: string[]): string | null {
if (keys.length === 0) return null;
return `project in (${keys.map(quote).join(", ")})`;
}
function sortClause(s: SortKey): string {
if (s === "created") return "ORDER BY created DESC";
if (s === "priority") return "ORDER BY priority DESC, updated DESC";
return "ORDER BY updated DESC";
}
export function filtersToJql(f: FilterState): string {
const clauses = [
projectClause(f.projectKeys),
statusClause(f.statusCategories),
assigneeClause(f.assignee),
searchClause(f.searchText),
].filter((c): c is string => c !== null);
const where = clauses.join(" AND ");
const order = sortClause(f.sort);
return where ? `${where} ${order}` : order;
}
export function filtersEqual(a: FilterState, b: FilterState): boolean {
return (
a.assignee === b.assignee &&
a.sort === b.sort &&
a.searchText === b.searchText &&
a.projectKeys.length === b.projectKeys.length &&
a.projectKeys.every((k, i) => k === b.projectKeys[i]) &&
a.statusCategories.length === b.statusCategories.length &&
a.statusCategories.every((c, i) => c === b.statusCategories[i])
);
}
@@ -0,0 +1,195 @@
"use client";
import { useMemo, useState } from "react";
import { IconChevronDown, IconX } from "@tabler/icons-react";
import { Checkbox } from "@kandev/ui/checkbox";
import { Input } from "@kandev/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import type { JiraProject, JiraStatusCategory } from "@/lib/types/jira";
import { STATUS_CATEGORY_OPTIONS, type AssigneeFilter } from "./filter-model";
type PillShellProps = {
label: string;
summary: string | null;
active: boolean;
onClear?: () => void;
children: React.ReactNode;
};
function PillShell({ label, summary, active, onClear, children }: PillShellProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<div
className={`inline-flex items-stretch rounded-md border text-xs overflow-hidden ${
active ? "border-primary/40 bg-primary/5" : "bg-background"
}`}
>
<PopoverTrigger asChild>
<button
type="button"
className="cursor-pointer px-2.5 py-1.5 flex items-center gap-1.5 hover:bg-muted/50 transition-colors"
>
<span className="text-muted-foreground">{label}</span>
{summary && <span className="font-medium">{summary}</span>}
<IconChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
{active && onClear && (
<button
type="button"
onClick={onClear}
className="cursor-pointer px-1.5 border-l hover:bg-muted flex items-center"
title={`Clear ${label.toLowerCase()}`}
>
<IconX className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
<PopoverContent align="start" className="w-64 p-0">
{children}
</PopoverContent>
</Popover>
);
}
function joinSummary(items: string[], max: number): string {
if (items.length <= max) return items.join(", ");
return `${items.slice(0, max).join(", ")} +${items.length - max}`;
}
type ProjectPillProps = {
projects: JiraProject[];
value: string[];
onChange: (keys: string[]) => void;
};
export function ProjectPill({ projects, value, onChange }: ProjectPillProps) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return projects;
return projects.filter(
(p) => p.key.toLowerCase().includes(q) || p.name.toLowerCase().includes(q),
);
}, [projects, query]);
const selected = new Set(value);
const toggle = (key: string) => {
if (selected.has(key)) onChange(value.filter((k) => k !== key));
else onChange([...value, key]);
};
return (
<PillShell
label="Project"
summary={value.length > 0 ? joinSummary(value, 2) : null}
active={value.length > 0}
onClear={() => onChange([])}
>
<div className="p-2 border-b">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search projects…"
className="h-7 text-xs"
/>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filtered.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">No projects match.</div>
)}
{filtered.map((p) => (
<label
key={p.key}
className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-muted/50"
>
<Checkbox checked={selected.has(p.key)} onCheckedChange={() => toggle(p.key)} />
<span className="font-mono text-xs">{p.key}</span>
<span className="text-xs text-muted-foreground truncate">{p.name}</span>
</label>
))}
</div>
</PillShell>
);
}
type StatusPillProps = {
value: JiraStatusCategory[];
onChange: (cats: JiraStatusCategory[]) => void;
};
export function StatusPill({ value, onChange }: StatusPillProps) {
const selected = new Set(value);
const toggle = (cat: JiraStatusCategory) => {
if (selected.has(cat)) onChange(value.filter((c) => c !== cat));
else onChange([...value, cat]);
};
const summary =
value.length > 0
? joinSummary(
value.map((c) => STATUS_CATEGORY_OPTIONS.find((o) => o.value === c)?.label ?? c),
2,
)
: null;
return (
<PillShell
label="Status"
summary={summary}
active={value.length > 0}
onClear={() => onChange([])}
>
<div className="py-1">
{STATUS_CATEGORY_OPTIONS.map((o) => (
<label
key={o.value}
className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-muted/50"
>
<Checkbox checked={selected.has(o.value)} onCheckedChange={() => toggle(o.value)} />
<span className="text-sm">{o.label}</span>
</label>
))}
</div>
</PillShell>
);
}
type AssigneePillProps = {
value: AssigneeFilter;
onChange: (a: AssigneeFilter) => void;
};
const ASSIGNEE_OPTIONS: { value: AssigneeFilter; label: string }[] = [
{ value: "anyone", label: "Anyone" },
{ value: "me", label: "Me" },
{ value: "unassigned", label: "Unassigned" },
];
export function AssigneePill({ value, onChange }: AssigneePillProps) {
const active = value !== "anyone";
const summary = active ? (ASSIGNEE_OPTIONS.find((o) => o.value === value)?.label ?? null) : null;
return (
<PillShell
label="Assignee"
summary={summary}
active={active}
onClear={() => onChange("anyone")}
>
<div className="py-1">
{ASSIGNEE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer hover:bg-muted/50 ${
value === o.value ? "font-medium" : ""
}`}
>
{o.label}
</button>
))}
</div>
</PillShell>
);
}
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { IconInfoCircle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
type JqlEditorProps = {
composedJql: string;
customJql: string | null;
onApply: (jql: string) => void;
onReset: () => void;
};
// Inline editor that exposes the JQL being sent to Jira. Users can tweak it
// for one-off advanced queries; pressing Reset returns to pill-driven mode.
export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEditorProps) {
const source = customJql ?? composedJql;
const [draft, setDraft] = useState(source);
const [prevSource, setPrevSource] = useState(source);
// React-recommended pattern for resetting state when a prop changes: compare
// in render and call setState — avoids an extra effect pass.
if (source !== prevSource) {
setPrevSource(source);
setDraft(source);
}
const dirty = draft.trim() !== source.trim();
const overriding = customJql !== null;
return (
<div className="px-6 py-3 border-b shrink-0 bg-muted/30 space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<IconInfoCircle className="h-3.5 w-3.5" />
{overriding ? (
<span>Custom JQL override — pill filters are paused.</span>
) : (
<span>JQL is generated from the pills above. Edit to override.</span>
)}
</div>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
className="font-mono text-xs min-h-[72px] bg-background"
/>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => onApply(draft)}
disabled={!dirty || !draft.trim()}
className="cursor-pointer h-7 text-xs"
>
Apply
</Button>
{overriding && (
<Button
size="sm"
variant="outline"
onClick={onReset}
className="cursor-pointer h-7 text-xs"
>
Reset to pills
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,335 @@
"use client";
import { useState } from "react";
import {
IconArrowsSort,
IconBookmark,
IconCheck,
IconCode,
IconPlus,
IconRefresh,
IconSearch,
IconTrash,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import type { SavedView } from "./use-saved-views";
import type { SortKey } from "./filter-model";
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
{ value: "updated", label: "Updated" },
{ value: "created", label: "Created" },
{ value: "priority", label: "Priority" },
];
type ListToolbarProps = {
searchText: string;
onSearchChange: (text: string) => void;
views: SavedView[];
activeViewId: string | null;
onSelectView: (id: string) => void;
onDeleteView: (id: string) => void;
onSaveView: (name: string) => void;
count: number;
loading: boolean;
sort: SortKey;
onSortChange: (sort: SortKey) => void;
onRefresh: () => void;
showJqlEditor: boolean;
onToggleJqlEditor: () => void;
};
export function ListToolbar({
searchText,
onSearchChange,
views,
activeViewId,
onSelectView,
onDeleteView,
onSaveView,
count,
loading,
sort,
onSortChange,
onRefresh,
showJqlEditor,
onToggleJqlEditor,
}: ListToolbarProps) {
const activeView = views.find((v) => v.id === activeViewId);
const sortLabel = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Updated";
return (
<div className="flex items-center gap-2 px-6 py-2.5 border-b shrink-0 flex-wrap">
<SearchInput value={searchText} onChange={onSearchChange} />
<ViewsDropdown
views={views}
activeViewId={activeViewId}
onSelect={onSelectView}
onDelete={onDeleteView}
activeName={activeView?.name}
/>
<SaveViewButton onSave={onSaveView} />
<div className="ml-auto flex items-center gap-1">
<span className="text-xs text-muted-foreground tabular-nums mr-2">
{loading ? "Loading…" : `${count} ticket${count === 1 ? "" : "s"} on this page`}
</span>
<SortDropdown sort={sort} sortLabel={sortLabel} onSortChange={onSortChange} />
<Button
variant="ghost"
size="icon-sm"
onClick={onRefresh}
disabled={loading}
className="cursor-pointer h-7 w-7"
title="Refresh"
>
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button
variant={showJqlEditor ? "default" : "ghost"}
size="sm"
onClick={onToggleJqlEditor}
className="cursor-pointer h-7 text-xs gap-1.5"
title="Toggle raw JQL editor"
>
<IconCode className="h-3.5 w-3.5" />
JQL
</Button>
</div>
</div>
);
}
function SortDropdown({
sort,
sortLabel,
onSortChange,
}: {
sort: SortKey;
sortLabel: string;
onSortChange: (sort: SortKey) => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="cursor-pointer h-7 text-xs gap-1.5">
<IconArrowsSort className="h-3.5 w-3.5" />
Sort: {sortLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{SORT_OPTIONS.map((o) => (
<DropdownMenuCheckboxItem
key={o.value}
checked={sort === o.value}
onCheckedChange={() => onSortChange(o.value)}
className="cursor-pointer"
>
{o.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function SearchInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<div className="relative flex-1 max-w-md min-w-[200px]">
<IconSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search ticket key or text…"
className="h-8 text-xs pl-8"
/>
</div>
);
}
function ViewsDropdown({
views,
activeViewId,
onSelect,
onDelete,
activeName,
}: {
views: SavedView[];
activeViewId: string | null;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
activeName: string | undefined;
}) {
const [open, setOpen] = useState(false);
const builtin = views.filter((v) => v.builtin);
const custom = views.filter((v) => !v.builtin);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="cursor-pointer h-8 text-xs gap-1.5">
<IconBookmark className="h-3.5 w-3.5" />
{activeName ?? "No view"}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-0">
<ViewsGroup
label="Built-in"
views={builtin}
activeViewId={activeViewId}
onSelect={(id) => {
onSelect(id);
setOpen(false);
}}
onDelete={onDelete}
/>
{custom.length > 0 && (
<>
<div className="border-t" />
<ViewsGroup
label="Saved"
views={custom}
activeViewId={activeViewId}
onSelect={(id) => {
onSelect(id);
setOpen(false);
}}
onDelete={onDelete}
/>
</>
)}
</PopoverContent>
</Popover>
);
}
function ViewsGroup({
label,
views,
activeViewId,
onSelect,
onDelete,
}: {
label: string;
views: SavedView[];
activeViewId: string | null;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="py-1">
<div className="px-3 py-1 text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">
{label}
</div>
{views.map((v) => (
<ViewRow
key={v.id}
view={v}
active={v.id === activeViewId}
onSelect={onSelect}
onDelete={onDelete}
/>
))}
</div>
);
}
function ViewRow({
view,
active,
onSelect,
onDelete,
}: {
view: SavedView;
active: boolean;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="group flex items-center px-2">
<button
type="button"
onClick={() => onSelect(view.id)}
className="flex-1 flex items-center gap-2 px-2 py-1.5 text-sm cursor-pointer rounded hover:bg-muted/50"
>
<IconCheck className={`h-3.5 w-3.5 ${active ? "opacity-100" : "opacity-0"}`} />
<span className="truncate">{view.name}</span>
</button>
{!view.builtin && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(view.id);
}}
className="cursor-pointer opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-muted"
title="Delete view"
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
);
}
function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const submit = () => {
const trimmed = name.trim();
if (!trimmed) return;
onSave(trimmed);
setName("");
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="cursor-pointer h-8 text-xs gap-1.5"
title="Save current filters as a view"
>
<IconPlus className="h-3.5 w-3.5" />
Save view
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-3 space-y-2">
<div className="text-xs font-semibold">Save current filters as…</div>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
placeholder="My open bugs"
className="h-8 text-xs"
/>
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => setOpen(false)}
className="cursor-pointer h-7 text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={submit}
disabled={!name.trim()}
className="cursor-pointer h-7 text-xs"
>
Save
</Button>
</div>
</PopoverContent>
</Popover>
);
}
+119
View File
@@ -0,0 +1,119 @@
import type { Icon } from "@tabler/icons-react";
import {
IconBug,
IconChecks,
IconCode,
IconEye,
IconMessageDots,
IconSearch,
IconSparkles,
IconTool,
} from "@tabler/icons-react";
// Runtime shape consumed by the dropdown menus. `prompt` is a resolver — for
// stored presets it interpolates the template; for fallback/defaults it's a
// plain string-builder.
export type JiraTaskPreset = {
id: string;
label: string;
hint: string;
icon: Icon;
prompt: (opts: { url: string; key: string; title: string; description: string }) => string;
};
export type JiraPresetIcon =
| "code"
| "search"
| "eye"
| "message"
| "tool"
| "bug"
| "sparkle"
| "check";
// Persisted shape — stored in localStorage and edited from settings.
export type JiraStoredPreset = {
id: string;
label: string;
hint: string;
icon: JiraPresetIcon | (string & {});
prompt_template: string;
};
export const PRESET_ICON_CHOICES: { key: JiraPresetIcon; icon: Icon; label: string }[] = [
{ key: "code", icon: IconCode, label: "Code" },
{ key: "search", icon: IconSearch, label: "Search" },
{ key: "eye", icon: IconEye, label: "Eye" },
{ key: "message", icon: IconMessageDots, label: "Message" },
{ key: "tool", icon: IconTool, label: "Tool" },
{ key: "bug", icon: IconBug, label: "Bug" },
{ key: "sparkle", icon: IconSparkles, label: "Sparkle" },
{ key: "check", icon: IconChecks, label: "Check" },
];
const ICON_BY_KEY: Record<string, Icon> = Object.fromEntries(
PRESET_ICON_CHOICES.map((c) => [c.key, c.icon]),
);
export function iconForPresetKey(key: string | undefined): Icon {
if (!key) return IconSparkles;
return ICON_BY_KEY[key] ?? IconSparkles;
}
// Interpolate `{{url}}`, `{{key}}`, `{{title}}`, `{{description}}` placeholders.
// Also supports single-brace `{foo}` for convenience. Empty description falls
// back to "(no description)" so prompts read naturally.
export function interpolateJiraTemplate(
template: string,
opts: { url: string; key: string; title: string; description: string },
): string {
const description = opts.description || "(no description)";
return template.replace(/\{\{?(url|key|title|description)\}\}?/g, (_m, k: string) => {
switch (k) {
case "url":
return opts.url;
case "key":
return opts.key;
case "title":
return opts.title;
case "description":
return description;
default:
return _m;
}
});
}
export function toTaskPreset(stored: JiraStoredPreset): JiraTaskPreset {
return {
id: stored.id,
label: stored.label,
hint: stored.hint,
icon: iconForPresetKey(stored.icon),
prompt: (opts) => interpolateJiraTemplate(stored.prompt_template, opts),
};
}
export const DEFAULT_JIRA_PRESETS: JiraStoredPreset[] = [
{
id: "implement",
label: "Implement",
hint: "Build the change, open a PR",
icon: "code",
prompt_template:
"Implement the change described in Jira ticket {{key}} ({{url}}).\n\nSummary: {{title}}\n\nDescription:\n{{description}}\n\nWhen done, open a pull request and link it back to {{key}}.",
},
{
id: "investigate",
label: "Investigate",
hint: "Find the root cause",
icon: "search",
prompt_template:
"Investigate Jira ticket {{key}} ({{url}}).\n\nSummary: {{title}}\n\nDescription:\n{{description}}\n\nIdentify the root cause and summarise findings; do not make code changes unless asked.",
},
];
export function resolveJiraTaskPresets(stored: JiraStoredPreset[] | null): JiraTaskPreset[] {
const source = stored?.length ? stored : DEFAULT_JIRA_PRESETS;
return source.map(toTaskPreset);
}
@@ -0,0 +1,82 @@
"use client";
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import type { Task, Workflow, WorkflowStep } from "@/lib/types/http";
import type { JiraTicket } from "@/lib/types/jira";
import type { JiraTaskPreset } from "./presets";
export type JiraLaunchPayload = {
ticket: JiraTicket;
preset: JiraTaskPreset;
};
type QuickTaskLauncherProps = {
workspaceId: string | null;
workflows: Workflow[];
steps: WorkflowStep[];
payload: JiraLaunchPayload | null;
onClose: () => void;
};
function buildDialogState(payload: JiraLaunchPayload) {
const { ticket, preset } = payload;
const title = `${ticket.key}: ${ticket.summary}`;
const description = preset.prompt({
url: ticket.url,
key: ticket.key,
title: ticket.summary,
description: ticket.description,
});
return { title, description };
}
export function QuickTaskLauncher({
workspaceId,
workflows,
steps,
payload,
onClose,
}: QuickTaskLauncherProps) {
const router = useRouter();
const defaultWorkflow = workflows[0];
const sortedStepsForWorkflow = useMemo(
() =>
steps
.filter((s) => s.workflow_id === defaultWorkflow?.id)
.sort((a, b) => a.position - b.position),
[steps, defaultWorkflow],
);
const defaultStep = sortedStepsForWorkflow[0];
const stepsForWorkflow = useMemo(
() => sortedStepsForWorkflow.map((s) => ({ id: s.id, title: s.name, events: s.events })),
[sortedStepsForWorkflow],
);
const dialog = useMemo(() => (payload ? buildDialogState(payload) : null), [payload]);
const handleOpenChange = (open: boolean) => {
if (!open) onClose();
};
const handleSuccess = (task: Task) => {
onClose();
router.push(`/t/${task.id}`);
};
if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null;
return (
<TaskCreateDialog
open={true}
onOpenChange={handleOpenChange}
mode="create"
workspaceId={workspaceId}
workflowId={defaultWorkflow.id}
defaultStepId={defaultStep.id}
steps={stepsForWorkflow}
initialValues={{ title: dialog.title, description: dialog.description }}
onSuccess={handleSuccess}
/>
);
}
@@ -0,0 +1,72 @@
"use client";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@kandev/ui/pagination";
type ResultsPaginationProps = {
page: number;
pageSize: number;
itemCount: number;
isLast: boolean;
onNext: () => void;
onPrev: () => void;
};
// Token-based pagination from Atlassian's /search/jql gives no total count, so
// we render forward/backward controls plus the current page's row range.
export function ResultsPagination({
page,
pageSize,
itemCount,
isLast,
onNext,
onPrev,
}: ResultsPaginationProps) {
if (page === 1 && isLast) return null;
const start = (page - 1) * pageSize + 1;
const end = (page - 1) * pageSize + itemCount;
const prevDisabled = page <= 1;
const nextDisabled = isLast;
return (
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
<div className="text-xs text-muted-foreground tabular-nums">
{itemCount === 0 ? "No results" : `${start}–${end}`}
</div>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (!prevDisabled) onPrev();
}}
aria-disabled={prevDisabled}
className={prevDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
<PaginationItem>
<span className="px-3 text-sm tabular-nums">Page {page}</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (!nextDisabled) onNext();
}}
aria-disabled={nextDisabled}
className={nextDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { IconExternalLink, IconPlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import type { JiraTicket } from "@/lib/types/jira";
import { formatRelative, statusBadgeClass } from "@/components/jira/jira-ticket-common";
import type { JiraTaskPreset } from "./presets";
type TicketRowProps = {
ticket: JiraTicket;
presets: JiraTaskPreset[];
onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
onOpen?: (ticket: JiraTicket) => void;
};
function AssigneeCell({ ticket }: { ticket: JiraTicket }) {
if (!ticket.assigneeName) {
return <span className="text-xs text-muted-foreground">Unassigned</span>;
}
return (
<div className="flex items-center gap-1.5 min-w-0">
<Avatar size="sm" className="size-5">
{ticket.assigneeAvatar && (
<AvatarImage src={ticket.assigneeAvatar} alt={ticket.assigneeName} />
)}
<AvatarFallback className="text-[10px]">{ticket.assigneeName.charAt(0)}</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground truncate">{ticket.assigneeName}</span>
</div>
);
}
function StartTaskMenu({
ticket,
presets,
onStartTask,
}: {
ticket: JiraTicket;
presets: JiraTaskPreset[];
onStartTask: TicketRowProps["onStartTask"];
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer h-7 px-2 gap-1 text-xs">
<IconPlus className="h-3.5 w-3.5" />
Start task
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{presets.map((p) => {
const Icon = p.icon;
return (
<DropdownMenuItem
key={p.id}
onClick={() => onStartTask(ticket, p)}
className="cursor-pointer"
>
<Icon className="h-4 w-4 mr-2" />
<div className="flex flex-col">
<span>{p.label}</span>
<span className="text-[11px] text-muted-foreground">{p.hint}</span>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function TicketRow({ ticket, presets, onStartTask, onOpen }: TicketRowProps) {
const relative = formatRelative(ticket.updated);
return (
<div className="flex items-start gap-3 py-3 border-b last:border-b-0">
<button
type="button"
onClick={() => onOpen?.(ticket)}
className="flex-1 min-w-0 space-y-1 text-left cursor-pointer rounded -mx-2 px-2 py-1 hover:bg-muted/50 transition-colors"
title="Open ticket details"
>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{ticket.key}</span>
{ticket.issueType && <span>· {ticket.issueType}</span>}
{ticket.priority && <span>· {ticket.priority}</span>}
</div>
<div className="text-sm font-medium truncate" title={ticket.summary}>
{ticket.summary}
</div>
<div className="flex items-center gap-2 flex-wrap">
{ticket.statusName && (
<Badge variant="outline" className={statusBadgeClass(ticket.statusCategory)}>
{ticket.statusName}
</Badge>
)}
<AssigneeCell ticket={ticket} />
{relative && <span className="text-xs text-muted-foreground">· updated {relative}</span>}
</div>
</button>
<div className="flex items-center gap-1 shrink-0">
<Button asChild variant="ghost" size="icon-sm" className="cursor-pointer">
<a href={ticket.url} target="_blank" rel="noreferrer" title="Open in Atlassian">
<IconExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
<StartTaskMenu ticket={ticket} presets={presets} onStartTask={onStartTask} />
</div>
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// Workspace-scoped: a workspace can have Jira configured but the user may
// want to silence the integration UI without removing credentials.
const storageKey = (workspaceId: string) => `kandev:jira:enabled:${workspaceId}:v1`;
// Custom event so multiple components in the same tab stay in sync; the
// `storage` event only fires across tabs.
const SYNC_EVENT = "kandev:jira:enabled-changed";
function readEnabled(workspaceId: string): boolean {
if (typeof window === "undefined") return true;
try {
const raw = window.localStorage.getItem(storageKey(workspaceId));
if (raw === null) return true;
return raw !== "false";
} catch {
return true;
}
}
export function useJiraEnabled(workspaceId: string | undefined) {
const [enabled, setEnabledState] = useState(true);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
async function init() {
if (cancelled) return;
if (workspaceId) setEnabledState(readEnabled(workspaceId));
setLoaded(true);
}
void init();
if (!workspaceId) return;
const onChange = () => setEnabledState(readEnabled(workspaceId));
window.addEventListener("storage", onChange);
window.addEventListener(SYNC_EVENT, onChange);
return () => {
cancelled = true;
window.removeEventListener("storage", onChange);
window.removeEventListener(SYNC_EVENT, onChange);
};
}, [workspaceId]);
const setEnabled = useCallback(
(next: boolean) => {
if (!workspaceId || typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey(workspaceId), String(next));
window.dispatchEvent(new Event(SYNC_EVENT));
} catch {
// Quota or private mode: state still updates in-memory.
}
setEnabledState(next);
},
[workspaceId],
);
return { enabled, setEnabled, loaded };
}
@@ -0,0 +1,91 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { searchJiraTickets } from "@/lib/api/domains/jira-api";
import type { JiraTicket } from "@/lib/types/jira";
type SearchState = {
items: JiraTicket[];
loading: boolean;
error: string | null;
page: number;
pageSize: number;
isLast: boolean;
lastFetchedAt: number | null;
goNext: () => void;
goPrev: () => void;
refresh: () => void;
};
const PAGE_SIZE = 25;
// Atlassian's /search/jql is token-paginated: each response carries a
// nextPageToken cursor for the page that follows. We cache tokens for visited
// pages so users can step backward without re-querying from page 1.
export function useJiraSearch(workspaceId: string | null | undefined, jql: string): SearchState {
const [items, setItems] = useState<JiraTicket[]>([]);
const [page, setPage] = useState(1);
const [isLast, setIsLast] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
// tokens[i] is the page_token for page i+1; tokens[0] is always "".
const tokensRef = useRef<string[]>([""]);
const reqRef = useRef(0);
const run = useCallback(
async (p: number) => {
if (!workspaceId || !jql.trim()) return;
const token = tokensRef.current[p - 1] ?? "";
const reqId = ++reqRef.current;
setLoading(true);
setError(null);
try {
const res = await searchJiraTickets(workspaceId, {
jql,
pageToken: token,
maxResults: PAGE_SIZE,
});
if (reqId !== reqRef.current) return;
setItems(res.tickets ?? []);
setIsLast(res.isLast ?? true);
if (!res.isLast && res.nextPageToken) {
tokensRef.current[p] = res.nextPageToken;
}
setLastFetchedAt(Date.now());
} catch (err) {
if (reqId !== reqRef.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (reqId === reqRef.current) setLoading(false);
}
},
[workspaceId, jql],
);
useEffect(() => {
tokensRef.current = [""];
setPage(1);
}, [workspaceId, jql]);
useEffect(() => {
void run(page);
}, [run, page]);
return {
items,
loading,
error,
page,
pageSize: PAGE_SIZE,
isLast,
lastFetchedAt,
goNext: () => {
if (!isLast) setPage((p) => p + 1);
},
goPrev: () => {
setPage((p) => Math.max(1, p - 1));
},
refresh: () => run(page),
};
}
@@ -0,0 +1,134 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { DEFAULT_FILTERS, type FilterState } from "./filter-model";
export type SavedView = {
id: string;
name: string;
filters: FilterState;
// customJql is set when the user saved the view while the raw JQL editor was
// overriding the structured filters. Restoring such a view re-applies the
// exact JQL string instead of recomposing it from `filters`.
customJql?: string | null;
builtin?: boolean;
};
const STORAGE_KEY = "kandev:jira:saved-views:v1";
const BUILTIN_VIEWS: SavedView[] = [
{
id: "builtin:assigned",
name: "Assigned to me",
builtin: true,
filters: { ...DEFAULT_FILTERS, assignee: "me" },
},
{
id: "builtin:in-progress",
name: "In progress",
builtin: true,
filters: { ...DEFAULT_FILTERS, assignee: "me", statusCategories: ["indeterminate"] },
},
{
id: "builtin:unassigned",
name: "Unassigned",
builtin: true,
filters: { ...DEFAULT_FILTERS, assignee: "unassigned" },
},
];
function readStorage(): SavedView[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(isSavedView);
} catch {
return [];
}
}
function isFilterState(f: unknown): f is FilterState {
if (!f || typeof f !== "object") return false;
const rec = f as Record<string, unknown>;
return (
Array.isArray(rec.projectKeys) &&
rec.projectKeys.every((k) => typeof k === "string") &&
Array.isArray(rec.statusCategories) &&
rec.statusCategories.every(
(c) => c === "" || c === "new" || c === "indeterminate" || c === "done",
) &&
(rec.assignee === "me" || rec.assignee === "unassigned" || rec.assignee === "anyone") &&
typeof rec.searchText === "string" &&
(rec.sort === "updated" || rec.sort === "created" || rec.sort === "priority")
);
}
function isSavedView(v: unknown): v is SavedView {
if (!v || typeof v !== "object") return false;
const rec = v as Record<string, unknown>;
return typeof rec.id === "string" && typeof rec.name === "string" && isFilterState(rec.filters);
}
function writeStorage(views: SavedView[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(views));
} catch {
// Quota or private-mode: swallow. Views just won't persist.
}
}
export function useSavedViews() {
const [custom, setCustom] = useState<SavedView[]>([]);
useEffect(() => {
let cancelled = false;
async function init() {
const loaded = readStorage();
if (!cancelled) setCustom(loaded);
}
void init();
return () => {
cancelled = true;
};
}, []);
const save = useCallback(
(name: string, filters: FilterState, customJql: string | null): SavedView => {
const view: SavedView = {
id: `custom:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
name,
filters,
customJql,
};
setCustom((prev) => {
const next = [...prev, view];
writeStorage(next);
return next;
});
return view;
},
[],
);
const remove = useCallback((id: string) => {
setCustom((prev) => {
const next = prev.filter((v) => v.id !== id);
writeStorage(next);
return next;
});
}, []);
return {
views: [...BUILTIN_VIEWS, ...custom],
builtin: BUILTIN_VIEWS,
custom,
save,
remove,
};
}
export const DEFAULT_VIEW = BUILTIN_VIEWS[0];
@@ -0,0 +1,96 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
DEFAULT_JIRA_PRESETS,
resolveJiraTaskPresets,
type JiraStoredPreset,
type JiraTaskPreset,
} from "./presets";
const STORAGE_KEY = "kandev:jira:task-presets:v1";
function isStoredPreset(v: unknown): v is JiraStoredPreset {
if (!v || typeof v !== "object") return false;
const rec = v as Record<string, unknown>;
return (
typeof rec.id === "string" &&
typeof rec.label === "string" &&
typeof rec.hint === "string" &&
typeof rec.icon === "string" &&
typeof rec.prompt_template === "string"
);
}
function readStorage(): JiraStoredPreset[] | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return null;
// An explicitly-saved empty array means "the user cleared their presets"
// and should beat the built-in defaults. Only the absent-key case should
// fall back to defaults.
return parsed.filter(isStoredPreset);
} catch {
return null;
}
}
function writeStorage(presets: JiraStoredPreset[] | null): void {
if (typeof window === "undefined") return;
try {
if (presets === null) {
window.localStorage.removeItem(STORAGE_KEY);
} else {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
}
} catch {
// Quota or private-mode: swallow. Presets just won't persist.
}
}
export function useJiraTaskPresets() {
const [stored, setStored] = useState<JiraStoredPreset[] | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
async function init() {
const value = readStorage();
if (!cancelled) {
setStored(value);
setLoaded(true);
}
}
void init();
return () => {
cancelled = true;
};
}, []);
const save = useCallback((next: JiraStoredPreset[]) => {
writeStorage(next);
setStored(next);
}, []);
const reset = useCallback(() => {
writeStorage(null);
setStored(null);
}, []);
const taskPresets = useMemo<JiraTaskPreset[]>(() => resolveJiraTaskPresets(stored), [stored]);
const storedOrDefault = stored ?? DEFAULT_JIRA_PRESETS;
return {
stored: storedOrDefault,
isCustomized: stored !== null,
taskPresets,
save,
reset,
loaded,
};
}
export { resolveJiraTaskPresets };
@@ -0,0 +1,258 @@
"use client";
import { createElement, useCallback, useMemo, useState } from "react";
import { IconPlus, IconRefresh, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { useJiraTaskPresets } from "@/components/jira/my-jira/use-task-presets";
import {
DEFAULT_JIRA_PRESETS,
PRESET_ICON_CHOICES,
iconForPresetKey,
type JiraStoredPreset,
} from "@/components/jira/my-jira/presets";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
const JIRA_PROMPT_PLACEHOLDERS: ScriptPlaceholder[] = [
{
key: "key",
description: "Jira ticket key",
example: "PROJ-123",
executor_types: [],
},
{
key: "url",
description: "URL of the Jira ticket",
example: "https://company.atlassian.net/browse/PROJ-123",
executor_types: [],
},
{
key: "title",
description: "Ticket summary",
example: "Login button broken on Safari",
executor_types: [],
},
{
key: "description",
description: "Ticket description",
example: "Repro: open Safari, click login…",
executor_types: [],
},
];
function newPreset(): JiraStoredPreset {
return {
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New action",
hint: "",
icon: "sparkle",
prompt_template: "",
};
}
function IconSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="!h-8 py-0.5 text-sm cursor-pointer" aria-label="Icon">
<SelectValue>
{createElement(iconForPresetKey(value), { className: "h-4 w-4" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PRESET_ICON_CHOICES.map((choice) => {
const ChoiceIcon = choice.icon;
return (
<SelectItem key={choice.key} value={choice.key} className="cursor-pointer">
<span className="flex items-center gap-2">
<ChoiceIcon className="h-3.5 w-3.5" />
{choice.label}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
function PresetRow({
preset,
expanded,
onToggle,
onPatch,
onRemove,
}: {
preset: JiraStoredPreset;
expanded: boolean;
onToggle: () => void;
onPatch: (patch: Partial<JiraStoredPreset>) => void;
onRemove: () => void;
}) {
return (
<div className="rounded-md border">
<div className="flex items-end gap-2 p-2">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground">Icon</span>
<IconSelect value={preset.icon} onChange={(v) => onPatch({ icon: v })} />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground">Label</span>
<Input
className="h-8 w-40"
value={preset.label}
placeholder="Label"
onChange={(e) => onPatch({ label: e.target.value })}
/>
</div>
<div className="flex flex-col gap-0.5 flex-1">
<span className="text-[10px] text-muted-foreground">Hint</span>
<Input
className="h-8"
value={preset.hint}
placeholder="Hint (optional)"
onChange={(e) => onPatch({ hint: e.target.value })}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-8 cursor-pointer text-xs"
onClick={onToggle}
>
{expanded ? "Hide prompt" : "Edit prompt"}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-destructive"
onClick={onRemove}
aria-label="Remove"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
{expanded && (
<div className="px-2 pb-2 space-y-1">
<div className="rounded-md border overflow-hidden">
<ScriptEditor
value={preset.prompt_template}
onChange={(v) => onPatch({ prompt_template: v })}
language="markdown"
height={computeEditorHeight(preset.prompt_template)}
lineNumbers="off"
placeholders={JIRA_PROMPT_PLACEHOLDERS}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
Type {"{{"} to see available placeholders.{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{key}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{url}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{title}}"}</code>, and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{description}}"}</code>{" "}
are substituted when the action runs.
</p>
</div>
)}
</div>
);
}
function usePresetDraft() {
const { stored, save: persistSave, reset: persistReset, loaded } = useJiraTaskPresets();
const [draft, setDraft] = useState<JiraStoredPreset[]>(stored);
// Render-time conditional setState is React's documented "adjust state
// during render" pattern; it resets the draft when the hook's stored value
// changes (e.g. after reset or cross-tab edit). Gate the sync on `loaded` so
// an in-progress edit isn't wiped when the initial localStorage read lands.
const [synced, setSynced] = useState(stored);
if (loaded && stored !== synced) {
setSynced(stored);
setDraft(stored);
}
const dirty = useMemo(() => JSON.stringify(stored) !== JSON.stringify(draft), [stored, draft]);
const save = useCallback(() => persistSave(draft), [persistSave, draft]);
const reset = useCallback(() => {
persistReset();
setDraft(DEFAULT_JIRA_PRESETS);
}, [persistReset]);
return { draft, setDraft, dirty, save, reset, loaded };
}
export function TaskPresetsSection() {
const { toast } = useToast();
const { draft, setDraft, dirty, save, reset, loaded } = usePresetDraft();
const [expandedId, setExpandedId] = useState<string | null>(null);
const patch = useCallback(
(index: number, change: Partial<JiraStoredPreset>) => {
setDraft(draft.map((p, i) => (i === index ? { ...p, ...change } : p)));
},
[draft, setDraft],
);
const remove = useCallback(
(index: number) => setDraft(draft.filter((_, i) => i !== index)),
[draft, setDraft],
);
const add = useCallback(() => {
const created = newPreset();
setDraft([...draft, created]);
setExpandedId(created.id);
}, [draft, setDraft]);
const handleSave = () => {
save();
toast({ description: "Task presets saved", variant: "success" });
};
const handleReset = () => {
reset();
toast({ description: "Task presets reset to defaults", variant: "success" });
};
return (
<SettingsSection
title="Task presets"
description="Prompts shown on /jira when starting a task from a ticket."
action={
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleReset}
disabled={!loaded}
className="cursor-pointer"
>
<IconRefresh className="h-3.5 w-3.5 mr-1" />
Reset
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty} className="cursor-pointer">
Save changes
</Button>
</div>
}
>
<div className="space-y-2">
{draft.map((preset, index) => (
<PresetRow
key={preset.id}
preset={preset}
expanded={expandedId === preset.id}
onToggle={() => setExpandedId((id) => (id === preset.id ? null : preset.id))}
onPatch={(p) => patch(index, p)}
onRemove={() => remove(index)}
/>
))}
<Button size="sm" variant="outline" onClick={add} className="cursor-pointer">
<IconPlus className="h-3.5 w-3.5 mr-1" />
Add preset
</Button>
</div>
</SettingsSection>
);
}
+78 -9
View File
@@ -14,7 +14,11 @@ import {
IconChartBar,
IconTimeline,
IconBrandGithub,
IconTicket,
} from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { getJiraConfig } from "@/lib/api/domains/jira-api";
import { useJiraEnabled } from "@/components/jira/my-jira/use-jira-enabled";
import { KanbanDisplayDropdown } from "../kanban-display-dropdown";
import { ReleaseNotesButton } from "../release-notes/release-notes-button";
import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog";
@@ -52,16 +56,75 @@ const VIEW_TOGGLE_ITEMS: ViewToggleItem[] = [
{ value: "list", icon: IconList, label: "List" },
];
// How often to refresh the cached config so the indicator picks up new probe
// results from the backend poller. The backend probes every 90s; refreshing at
// the same cadence keeps the indicator no more than ~one cycle stale.
const JIRA_STATUS_REFRESH_MS = 90_000;
// Reads the backend-recorded auth health for a workspace. The actual probing
// happens server-side (jira.Poller); this hook just polls getJiraConfig to
// pick up the latest result. Returns false until a config has been loaded so
// we don't briefly flash a green check on first mount.
function useJiraAuthed(workspaceId: string | undefined): boolean {
const [authed, setAuthed] = useState(false);
useEffect(() => {
// The return-value branch below covers the no-workspace case; calling
// setAuthed here would trip the no-setState-in-effect lint rule.
if (!workspaceId) return;
let cancelled = false;
async function refresh() {
try {
const cfg = await getJiraConfig(workspaceId!);
if (cancelled) return;
setAuthed(!!cfg?.hasSecret && !!cfg.lastOk);
} catch {
if (!cancelled) setAuthed(false);
}
}
void refresh();
const id = setInterval(() => void refresh(), JIRA_STATUS_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [workspaceId]);
return workspaceId ? authed : false;
}
function GitHubTopbarButton() {
const { status } = useGitHubStatus();
if (!status?.authenticated) return null;
return (
<Button variant="outline" asChild className="cursor-pointer gap-2">
<Link href="/github">
<IconBrandGithub className="h-4 w-4" />
<span>GitHub</span>
</Link>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" asChild className="cursor-pointer">
<Link href="/github">
<IconBrandGithub className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>GitHub</TooltipContent>
</Tooltip>
);
}
function JiraTopbarButton({ workspaceId }: { workspaceId: string | undefined }) {
const { enabled } = useJiraEnabled(workspaceId);
// Skip the keep-alive probe entirely when Jira is disabled — passing
// undefined short-circuits the hook's effect.
const authed = useJiraAuthed(enabled ? workspaceId : undefined);
if (!enabled || !authed) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" asChild className="cursor-pointer">
<Link href="/jira">
<IconTicket className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Jira</TooltipContent>
</Tooltip>
);
}
@@ -139,11 +202,14 @@ function TabletHeader({
}) {
return (
<header className="flex items-center justify-between p-4 pb-3 gap-3">
<div className="flex items-center gap-4 flex-shrink-0">
<div className="flex items-center gap-3 flex-shrink-0">
<Link href="/" className="text-xl font-bold hover:opacity-80">
KanDev
</Link>
<GitHubTopbarButton />
<TooltipProvider>
<GitHubTopbarButton />
<JiraTopbarButton workspaceId={workspaceId} />
</TooltipProvider>
</div>
{onSearchChange && (
<TaskSearchInput
@@ -221,7 +287,10 @@ function DesktopHeader({
KanDev
</Link>
<div className="flex items-center gap-3">
<GitHubTopbarButton />
<TooltipProvider>
<GitHubTopbarButton />
<JiraTopbarButton workspaceId={workspaceId} />
</TooltipProvider>
<Button variant="outline" asChild className="cursor-pointer gap-2">
<Link href="/stats">
<IconChartBar className="h-4 w-4" />
@@ -17,6 +17,7 @@ import {
IconWand,
IconGitBranch,
IconArrowsShuffle,
IconTicket,
} from "@tabler/icons-react";
import {
Sidebar,
@@ -106,6 +107,7 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec
const workflowsPath = `${workspacePath}/workflows`;
const repositoriesPath = `${workspacePath}/repositories`;
const githubPath = `${workspacePath}/github`;
const jiraPath = `${workspacePath}/jira`;
return (
<SidebarMenuSubItem key={workspace.id}>
@@ -143,6 +145,14 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild size="sm" isActive={pathname === jiraPath}>
<Link href={jiraPath}>
<IconTicket className="h-3.5 w-3.5" />
<span>Jira</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarMenuSubItem>
);
@@ -9,6 +9,7 @@ import { AgentLogo } from "@/components/agent-logo";
import type { DialogFormState } from "@/components/task-create-dialog-types";
import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
import { TaskFormInputs } from "@/components/task-create-dialog-selectors";
import type { JiraTicket } from "@/lib/types/jira";
type SelectorOption = {
value: string;
@@ -323,6 +324,8 @@ export type DialogPromptSectionProps = {
fs: DialogFormState;
handleKeyDown: ReturnType<typeof useKeyboardShortcutHandler>;
enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean };
workspaceId?: string | null;
onJiraImport?: (ticket: JiraTicket) => void;
};
export function DialogPromptSection({
@@ -334,7 +337,10 @@ export function DialogPromptSection({
fs,
handleKeyDown,
enhance,
workspaceId,
onJiraImport,
}: DialogPromptSectionProps) {
const showJiraImport = !isSessionMode && !isTaskStarted && !!onJiraImport;
return (
<>
<TaskFormInputs
@@ -350,6 +356,15 @@ export function DialogPromptSection({
onEnhancePrompt={enhance?.onEnhance}
isEnhancingPrompt={enhance?.isLoading}
isUtilityConfigured={enhance?.isConfigured}
jiraImport={
showJiraImport && onJiraImport
? {
workspaceId: workspaceId ?? null,
disabled: isPassthroughProfile,
onImport: onJiraImport,
}
: undefined
}
/>
{isPassthroughProfile && hasDescription && (
<p className="text-xs text-amber-500">Prompt ignored — passthrough mode active</p>
@@ -16,6 +16,8 @@ import { ContextZone } from "@/components/task/chat/context-items/context-zone";
import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context";
import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { JiraImportBar } from "@/components/jira/jira-import-bar";
import type { JiraTicket } from "@/lib/types/jira";
const CURSOR_POINTER_CLASS = "cursor-pointer";
@@ -252,6 +254,11 @@ type TaskFormInputsProps = {
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
isUtilityConfigured?: boolean;
jiraImport?: {
workspaceId: string | null;
disabled?: boolean;
onImport: (ticket: JiraTicket) => void;
};
};
function useFileAttachments() {
@@ -460,6 +467,44 @@ function useDescriptionInput(
return { description, textareaRef, handleDescriptionChange };
}
type FormInputsToolbarProps = {
onAttach: () => void;
disabled?: boolean;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
isUtilityConfigured?: boolean;
jiraImport?: TaskFormInputsProps["jiraImport"];
};
function FormInputsToolbar({
onAttach,
disabled,
onEnhancePrompt,
isEnhancingPrompt,
isUtilityConfigured,
jiraImport,
}: FormInputsToolbarProps) {
return (
<div className="flex items-center px-1 pb-1">
<AttachButton onClick={onAttach} disabled={disabled} />
{onEnhancePrompt && (
<EnhancePromptButton
onClick={onEnhancePrompt}
isLoading={isEnhancingPrompt ?? false}
isConfigured={isUtilityConfigured}
/>
)}
{jiraImport && (
<JiraImportBar
workspaceId={jiraImport.workspaceId}
disabled={jiraImport.disabled}
onImport={jiraImport.onImport}
/>
)}
</div>
);
}
export const TaskFormInputs = memo(function TaskFormInputs({
isSessionMode,
autoFocus,
@@ -472,6 +517,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({
onEnhancePrompt,
isEnhancingPrompt,
isUtilityConfigured,
jiraImport,
}: TaskFormInputsProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment } =
@@ -531,16 +577,14 @@ export const TaskFormInputs = memo(function TaskFormInputs({
required={isSessionMode}
disabled={disabled}
/>
<div className="flex items-center px-1 pb-1">
<AttachButton onClick={handleAttachClick} disabled={disabled} />
{onEnhancePrompt && (
<EnhancePromptButton
onClick={onEnhancePrompt}
isLoading={isEnhancingPrompt ?? false}
isConfigured={isUtilityConfigured}
/>
)}
</div>
<FormInputsToolbar
onAttach={handleAttachClick}
disabled={disabled}
onEnhancePrompt={onEnhancePrompt}
isEnhancingPrompt={isEnhancingPrompt}
isUtilityConfigured={isUtilityConfigured}
jiraImport={jiraImport}
/>
<input
ref={fileInputRef}
type="file"
@@ -1,6 +1,7 @@
"use client";
import { FormEvent, useCallback } from "react";
import type { JiraTicket } from "@/lib/types/jira";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
import type { Task } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
@@ -239,6 +240,8 @@ type DialogFormBodyProps = {
isPassthroughProfile: boolean;
initialDescription: string;
hasDescription: boolean;
workspaceId: string | null;
onJiraImport?: (ticket: JiraTicket) => void;
branchOptions: ReturnType<typeof useBranchOptions>;
branchesLoading: boolean;
agentProfileOptions: ReturnType<typeof useAgentProfileOptions>;
@@ -273,6 +276,8 @@ function DialogFormBody({
isPassthroughProfile,
initialDescription,
hasDescription,
workspaceId,
onJiraImport,
branchOptions,
branchesLoading,
agentProfileOptions,
@@ -306,6 +311,8 @@ function DialogFormBody({
fs={fs}
handleKeyDown={handleKeyDown}
enhance={enhance}
workspaceId={workspaceId}
onJiraImport={onJiraImport}
/>
{!isSessionMode && (
<CreateEditSelectors
@@ -379,6 +386,22 @@ function useEnhanceForDialog(fs: DialogFormState) {
return { onEnhance, isLoading: isEnhancingPrompt, isConfigured };
}
function useJiraImportHandler(fs: DialogFormState) {
return useCallback(
(ticket: JiraTicket) => {
const title = `[${ticket.key}] ${ticket.summary}`;
fs.setTaskName(title);
fs.setHasTitle(true);
const description = ticket.description?.trim()
? `${ticket.description}\n\n---\nJira: ${ticket.url}`
: `Jira: ${ticket.url}`;
fs.descriptionInputRef.current?.setValue(description);
fs.setHasDescription(true);
},
[fs],
);
}
function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
const { open, onOpenChange, mode = "create", workspaceId, workflowId, defaultStepId } = props;
const { editingTask, onSuccess, onCreateSession, initialValues, parentTaskId } = props;
@@ -473,6 +496,7 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
submitHandlers,
handleKeyDown,
enhance: useEnhanceForDialog(fs),
handleJiraImport: useJiraImportHandler(fs),
};
}
@@ -484,6 +508,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
const { repositoriesLoading, branchesLoading, computed, handlers, handleKeyDown } = setup;
const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers;
const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers;
const { handleJiraImport } = setup;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
@@ -521,6 +546,8 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
isPassthroughProfile={computed.isPassthroughProfile}
initialDescription={fs.currentDefaults.description}
hasDescription={fs.hasDescription}
workspaceId={workspaceId}
onJiraImport={handleJiraImport}
branchOptions={computed.branchOptions}
branchesLoading={branchesLoading || (fs.useGitHubUrl && fs.githubBranchesLoading)}
agentProfileOptions={computed.agentProfileOptions}
+13
View File
@@ -21,6 +21,8 @@ import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
import { DocumentControls } from "@/components/task/document/document-controls";
import { VcsSplitButton } from "@/components/vcs-split-button";
import { PRTopbarButton } from "@/components/github/pr-topbar-button";
import { JiraTicketButton, extractJiraKey } from "@/components/jira/jira-ticket-button";
import { JiraLinkButton } from "@/components/jira/jira-link-button";
import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
@@ -131,6 +133,7 @@ const TaskTopBar = memo(function TaskTopBar({
/>
)}
<TopBarRight
taskId={taskId}
activeSessionId={activeSessionId}
baseBranch={baseBranch}
gitStatus={git}
@@ -140,6 +143,7 @@ const TaskTopBar = memo(function TaskTopBar({
workspaceId={workspaceId}
isRemoteExecutor={isRemoteExecutor}
isAgentctlReady={isAgentctlReady}
taskTitle={taskTitle}
/>
</header>
);
@@ -292,6 +296,7 @@ function GitAheadBehindBadges({
/** Right section: git badges, debug toggle, document controls, editors, VCS, settings */
function TopBarRight({
taskId,
activeSessionId,
baseBranch,
gitStatus,
@@ -301,7 +306,9 @@ function TopBarRight({
workspaceId,
isRemoteExecutor,
isAgentctlReady,
taskTitle,
}: {
taskId?: string | null;
activeSessionId?: string | null;
baseBranch?: string;
gitStatus: { ahead: number; behind: number };
@@ -311,6 +318,7 @@ function TopBarRight({
workspaceId?: string | null;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
taskTitle?: string;
}) {
return (
<div className="flex items-center gap-2 justify-end">
@@ -341,6 +349,11 @@ function TopBarRight({
isAgentctlReady={isAgentctlReady}
/>
<PRTopbarButton />
{extractJiraKey(taskTitle) ? (
<JiraTicketButton workspaceId={workspaceId} taskTitle={taskTitle} />
) : (
<JiraLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
)}
<QuickChatButton workspaceId={workspaceId} />
<VcsSplitButton sessionId={activeSessionId ?? null} baseBranch={baseBranch} />
<LayoutPresetSelector />
+91
View File
@@ -0,0 +1,91 @@
import { fetchJson, type ApiRequestOptions } from "../client";
import type {
JiraConfig,
JiraProject,
JiraSearchResult,
JiraTicket,
SetJiraConfigRequest,
TestJiraConnectionResult,
} from "@/lib/types/jira";
// getJiraConfig returns null when the backend responds 204 (no config yet).
// fetchJson already maps 204 → undefined; we narrow it to null for callers.
export async function getJiraConfig(
workspaceId: string,
options?: ApiRequestOptions,
): Promise<JiraConfig | null> {
const res = await fetchJson<JiraConfig | undefined>(
`/api/v1/jira/config?workspace_id=${encodeURIComponent(workspaceId)}`,
options,
);
return res ?? null;
}
export async function setJiraConfig(payload: SetJiraConfigRequest, options?: ApiRequestOptions) {
return fetchJson<JiraConfig>(`/api/v1/jira/config`, {
...options,
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) },
});
}
export async function deleteJiraConfig(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<{ deleted: boolean }>(
`/api/v1/jira/config?workspace_id=${encodeURIComponent(workspaceId)}`,
{ ...options, init: { ...(options?.init ?? {}), method: "DELETE" } },
);
}
export async function testJiraConnection(
payload: SetJiraConfigRequest,
options?: ApiRequestOptions,
) {
return fetchJson<TestJiraConnectionResult>(`/api/v1/jira/config/test`, {
...options,
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) },
});
}
export async function listJiraProjects(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<{ projects: JiraProject[] }>(
`/api/v1/jira/projects?workspace_id=${encodeURIComponent(workspaceId)}`,
options,
);
}
export async function getJiraTicket(
workspaceId: string,
ticketKey: string,
options?: ApiRequestOptions,
) {
return fetchJson<JiraTicket>(
`/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}?workspace_id=${encodeURIComponent(workspaceId)}`,
options,
);
}
export async function searchJiraTickets(
workspaceId: string,
params: { jql?: string; pageToken?: string; maxResults?: number },
options?: ApiRequestOptions,
) {
const search = new URLSearchParams({ workspace_id: workspaceId });
if (params.jql) search.set("jql", params.jql);
if (params.pageToken) search.set("page_token", params.pageToken);
if (params.maxResults) search.set("max_results", String(params.maxResults));
return fetchJson<JiraSearchResult>(`/api/v1/jira/tickets?${search.toString()}`, options);
}
export async function transitionJiraTicket(
workspaceId: string,
ticketKey: string,
transitionId: string,
options?: ApiRequestOptions,
) {
return fetchJson<{ transitioned: boolean }>(
`/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}/transitions?workspace_id=${encodeURIComponent(workspaceId)}`,
{
...options,
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ transitionId }) },
},
);
}
+81
View File
@@ -0,0 +1,81 @@
export type JiraAuthMethod = "api_token" | "session_cookie";
export interface JiraConfig {
workspaceId: string;
siteUrl: string;
email: string;
authMethod: JiraAuthMethod;
defaultProjectKey: string;
hasSecret: boolean;
/** ISO timestamp when the session cookie's JWT expires, or null for api_token / opaque cookies. */
secretExpiresAt?: string | null;
/** Last time the backend probed credentials, or null if never probed. */
lastCheckedAt?: string | null;
/** Whether the most recent backend probe succeeded. */
lastOk: boolean;
/** Error message from the most recent failed probe; empty when ok or unprobed. */
lastError?: string;
createdAt: string;
updatedAt: string;
}
export interface SetJiraConfigRequest {
workspaceId: string;
siteUrl: string;
email: string;
authMethod: JiraAuthMethod;
defaultProjectKey?: string;
secret?: string;
}
export interface TestJiraConnectionResult {
ok: boolean;
accountId?: string;
displayName?: string;
email?: string;
error?: string;
}
export interface JiraTransition {
id: string;
name: string;
toStatusId: string;
toStatusName: string;
}
export type JiraStatusCategory = "new" | "indeterminate" | "done" | "";
export interface JiraTicket {
key: string;
summary: string;
description: string;
statusId: string;
statusName: string;
statusCategory: JiraStatusCategory;
projectKey: string;
issueType: string;
issueTypeIcon?: string;
priority?: string;
priorityIcon?: string;
assigneeName?: string;
assigneeAvatar?: string;
reporterName?: string;
reporterAvatar?: string;
updated?: string;
url: string;
transitions: JiraTransition[];
fields?: Record<string, string>;
}
export interface JiraProject {
key: string;
name: string;
id: string;
}
export interface JiraSearchResult {
tickets: JiraTicket[];
maxResults: number;
isLast: boolean;
nextPageToken?: string;
}