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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Store persists Jira workspace configurations. Secret values are delegated to
|
||||
// the shared encrypted secret store and not stored here.
|
||||
type Store struct {
|
||||
db *sqlx.DB
|
||||
ro *sqlx.DB
|
||||
}
|
||||
|
||||
// NewStore creates a new Store and initializes the schema if needed.
|
||||
func NewStore(writer, reader *sqlx.DB) (*Store, error) {
|
||||
s := &Store{db: writer, ro: reader}
|
||||
if err := s.initSchema(); err != nil {
|
||||
return nil, fmt.Errorf("jira schema init: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
const createTablesSQL = `
|
||||
CREATE TABLE IF NOT EXISTS jira_configs (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
site_url TEXT NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
auth_method TEXT NOT NULL,
|
||||
default_project_key TEXT NOT NULL DEFAULT '',
|
||||
last_checked_at DATETIME,
|
||||
last_ok INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// addedColumns lists columns introduced after the initial schema. SQLite has no
|
||||
// portable `ADD COLUMN IF NOT EXISTS`, so we ask sqlite_master for the existing
|
||||
// CREATE TABLE statement and only ALTER when the column name is missing.
|
||||
var addedColumns = []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"last_checked_at", "ALTER TABLE jira_configs ADD COLUMN last_checked_at DATETIME"},
|
||||
{"last_ok", "ALTER TABLE jira_configs ADD COLUMN last_ok INTEGER NOT NULL DEFAULT 0"},
|
||||
{"last_error", "ALTER TABLE jira_configs ADD COLUMN last_error TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
|
||||
func (s *Store) initSchema() error {
|
||||
if _, err := s.db.Exec(createTablesSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateAddedColumns()
|
||||
}
|
||||
|
||||
// migrateAddedColumns applies ALTER TABLE statements for columns introduced
|
||||
// after the initial schema. Existing databases created with the old CREATE
|
||||
// TABLE need these columns backfilled; new databases already have them from
|
||||
// createTablesSQL and the ALTERs are skipped.
|
||||
func (s *Store) migrateAddedColumns() error {
|
||||
existing, err := s.tableColumns("jira_configs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, col := range addedColumns {
|
||||
if _, ok := existing[col.name]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := s.db.Exec(col.sql); err != nil {
|
||||
return fmt.Errorf("add column %s: %w", col.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) tableColumns(table string) (map[string]struct{}, error) {
|
||||
rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
cols := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notnull int
|
||||
dflt sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols[name] = struct{}{}
|
||||
}
|
||||
return cols, rows.Err()
|
||||
}
|
||||
|
||||
const selectConfigColumns = `workspace_id, site_url, email, auth_method, default_project_key,
|
||||
last_checked_at, last_ok, last_error, created_at, updated_at`
|
||||
|
||||
// GetConfig returns the Jira config for a workspace, or nil when no row exists.
|
||||
func (s *Store) GetConfig(ctx context.Context, workspaceID string) (*JiraConfig, error) {
|
||||
var cfg JiraConfig
|
||||
err := s.ro.GetContext(ctx, &cfg,
|
||||
`SELECT `+selectConfigColumns+` FROM jira_configs WHERE workspace_id = ?`, workspaceID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// UpsertConfig inserts or updates the config row for a workspace. It never
|
||||
// touches the secret store — callers must persist the token separately. The
|
||||
// last_* health columns are deliberately not touched here; the poller owns
|
||||
// those and writes them via UpdateAuthHealth.
|
||||
func (s *Store) UpsertConfig(ctx context.Context, cfg *JiraConfig) error {
|
||||
now := time.Now().UTC()
|
||||
if cfg.CreatedAt.IsZero() {
|
||||
cfg.CreatedAt = now
|
||||
}
|
||||
cfg.UpdatedAt = now
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO jira_configs (workspace_id, site_url, email, auth_method, default_project_key, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
site_url = excluded.site_url,
|
||||
email = excluded.email,
|
||||
auth_method = excluded.auth_method,
|
||||
default_project_key = excluded.default_project_key,
|
||||
updated_at = excluded.updated_at`,
|
||||
cfg.WorkspaceID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.DefaultProjectKey, cfg.CreatedAt, cfg.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteConfig removes the Jira config row for a workspace. Secrets must be
|
||||
// cleared separately by the caller.
|
||||
func (s *Store) DeleteConfig(ctx context.Context, workspaceID string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM jira_configs WHERE workspace_id = ?`, workspaceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListConfiguredWorkspaces returns the IDs of all workspaces that have a Jira
|
||||
// config row. Used by the auth-health poller to know which workspaces to probe.
|
||||
func (s *Store) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) {
|
||||
var ids []string
|
||||
err := s.ro.SelectContext(ctx, &ids,
|
||||
`SELECT workspace_id FROM jira_configs ORDER BY workspace_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// UpdateAuthHealth records the result of a credential probe for a workspace.
|
||||
// errMsg is the empty string when ok is true. If the workspace row no longer
|
||||
// exists (e.g. the user removed the config concurrently with the poller), the
|
||||
// update is a silent no-op rather than an error.
|
||||
func (s *Store) UpdateAuthHealth(ctx context.Context, workspaceID string, ok bool, errMsg string, checkedAt time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE jira_configs
|
||||
SET last_checked_at = ?, last_ok = ?, last_error = ?
|
||||
WHERE workspace_id = ?`,
|
||||
checkedAt, ok, errMsg, workspaceID)
|
||||
return err
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 }) },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user