refactor: extract shared shapes between jira and linear integrations (#759)

* refactor: extract shared shapes between jira and linear integrations

Factors out the duplicated logic between internal/jira + internal/linear
(backend) and components/jira + components/linear (frontend) into reusable
shared shapes. Follow-up to PR #736.

Backend:
- New internal/integrations/secretadapter wraps secrets.SecretStore with
  the upsert API both jira.SecretStore and linear.SecretStore expect;
  drops both adapter copies from cmd/kandev/services.go.
- New internal/integrations/healthpoll owns the 90s auth-health loop via
  a Prober interface; linear/poller.go is a thin wrapper, jira/poller.go
  keeps only its (jira-specific) issue-watch loop.
- provideServices split into initGitHubService / initJiraService /
  initLinearService — now 75 lines (was ~125, exceeded the 80-line limit).

Frontend:
- New hooks/domains/integrations/use-integration-availability.ts and
  use-integration-enabled.ts; jira/linear hooks reduce to one-line
  delegates and move from components/<x>/ to hooks/domains/<x>/ per the
  project convention Greptile flagged on PR #736.
- New components/integrations/{auth-status-banner,auth-error-message,
  validated-popover}.tsx; the four link/import-bar wrappers drop from
  ~110 lines to ~35 each, and JiraLinkButton picks up the
  clear-error-on-close behaviour LinearLinkButton already had.

Docs:
- New 'Adding a new third-party integration' section in CLAUDE.md/AGENTS.md
  documenting the shared shapes and intentional Jira/Linear divergence
  (transition vs state, JQL vs filter object, eventBus only on jira).

* fix: address PR #759 review feedback

- validated-popover: outline-with-label trigger now sets type="button" and
  honours triggerDisabled, matching ghost-icon (Greptile, Claude, CodeRabbit).
- jira-link-button / linear-link-button: move updateTask() into the awaited
  fetch path so a failed title update surfaces as the popover's inline error
  instead of an unhandled rejection (CodeRabbit).
- use-integration-availability: monotonic requestId race guard prevents a
  slow earlier probe from overwriting a newer auth result (CodeRabbit).
- use-integration-enabled: drop unnecessary async on synchronous init
  (Claude).
- jira/linear poller smoke tests: wrap in testing/synctest instead of
  time.After(2s) per backend testing convention (CodeRabbit nitpick).

Skipped with reasoning on the threads:
- secretadapter Set TOCTOU — single-process SQLite + single-writer per
  workspace; constraint violation on conflict surfaces as an error to the
  caller, not silent corruption.
- healthpoll SetInterval validation — the function isn't called from
  production code today; a panic on misuse would be a footgun and silent
  ignore would just hide bugs differently.

* fix: address PR #759 round-2 review feedback

- jira-settings / linear-settings: drop the local STATUS_REFRESH_MS = 90_000
  copies and import the shared INTEGRATION_STATUS_REFRESH_MS so changing the
  cadence only touches one file (Claude).
- healthpoll: rename Poller.ProbeAll → probeAll. The only callers are the
  loop and the in-package tests, so widening the public API wasn't buying
  anything (Claude).

* fix: invalidate ValidatedPopover in-flight submits on close

If the user clicked Submit and dismissed the popover before the request
settled, the next time they re-opened it would inherit `loading=true` (the
disabled-while-loading submit button was stuck) and a late rejection from
the previous session could repopulate `error` in the new one.

Track the submit via a `useRef` token, bump it on close, and ignore any
result/rejection whose token no longer matches. Also reset `loading` and
`error` on close so the next open starts clean. Adds a regression test
covering both the late-resolution and late-rejection paths.

Addresses CodeRabbit review on PR #759.

* ci: trigger workflow re-run

GitHub Actions did not dispatch the pull_request workflows on the previous
two pushes (1d869f0e, 0ab6c034); only pull_request_target and CodeQL fired.
Empty commit to nudge synchronize event.

* fix: address PR #759 round-3 review feedback

- jira.Poller.Stop: add nil-receiver guard so callers using the
  defer-Stop-after-NewPoller pattern don't panic when initJiraService
  returned nil (Greptile + Claude). Matches linear.Poller's guard.
- healthpoll TestStart_RunsImmediateProbe + TestStart_IsIdempotent:
  rewrap in testing/synctest with synctest.Wait() instead of
  time.After(2s) wall-clock waits. Drops the now-unused probedAll signal
  channel from fakeProber (Claude).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-05-02 11:50:19 +01:00
committed by GitHub
parent 91edce7eac
commit 83e01477df
39 changed files with 1575 additions and 1321 deletions
+26 -1
View File
@@ -87,6 +87,9 @@ apps/backend/
│ ├── gateway/ # WebSocket gateway
│ ├── github/ # GitHub API integration (PRs, reviews, webhooks)
│ ├── integration/ # External integrations
│ ├── integrations/ # Shared shapes for third-party integrations
│ │ ├── healthpoll/ # Reusable 90s auth-health Poller (used by jira, linear)
│ │ └── secretadapter/ # Upsert-style adapter over secrets.SecretStore
│ ├── jira/ # Jira/Atlassian Cloud integration (config, REST client, poller)
│ ├── linear/ # Linear integration (config, GraphQL client, poller)
│ ├── lsp/ # LSP server
@@ -324,6 +327,28 @@ Every code change must include tests for new or changed logic. Backend: `*_test.
### GitHub Operations
Skills use `gh` CLI by default. If a `gh` command fails (not installed, not authenticated, etc.), use whatever GitHub tools are available in the environment (MCP GitHub tools, API tools, etc.) to accomplish the same operation. The goal is the same — the tool may differ.
### Adding a new third-party integration
Jira and Linear are the model: per-workspace credentials, a 90s auth-health poller, a settings page with status banner + reconnect CTA, link/import buttons that gate on availability. New integrations should reuse the shared shapes rather than copying either.
**Backend** (`apps/backend/internal/<name>/`):
- Mirror the package layout: `service.go`, `store.go`, `client.go`, `provider.go`, `handlers.go`, `models.go`, `poller.go`. Expose `Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error)` (add `eventBus bus.EventBus` only if the integration actually publishes events — Linear does not).
- Use `internal/integrations/secretadapter` instead of writing your own upsert wrapper around `secrets.SecretStore`. The adapter satisfies any per-integration `SecretStore` interface shaped as `{Reveal, Set, Delete, Exists}`.
- Use `internal/integrations/healthpoll` for the auth-health loop. Implement the `Prober` interface (`ListConfiguredWorkspaces` + `RecordAuthHealth`) on a small adapter and let `healthpoll.New("name", prober, log)` own Start/Stop/ticker. Keep integration-specific loops (JQL polling, webhook reconciliation, etc.) separate, like jira's issue-watch loop.
- Wire the service via a per-domain `init<Name>Service(...)` helper in `cmd/kandev/services.go`, not inline in `provideServices`.
**Frontend**:
- Hooks live under `hooks/domains/<name>/`, **not** `components/<name>/`.
- Use `hooks/domains/integrations/use-integration-availability.ts` and `use-integration-enabled.ts` — each integration's `useXAvailable` / `useXEnabled` should be a one-line wrapper passing the storage key + sync event + config-fetch function.
- Settings page reuses `<IntegrationAuthStatusBanner>` (`components/integrations/auth-status-banner.tsx`).
- "Auth required / reconnect" UI reuses `<IntegrationAuthErrorMessage>` (`components/integrations/auth-error-message.tsx`) — supply the integration's display name, regex check, and reconnect href.
- Link / import popovers reuse `<ValidatedPopover>` (`components/integrations/validated-popover.tsx`) — supply the icon, label, key regex, fetch function, and success callback.
**Where Jira and Linear deliberately diverge:**
- Issue model: Jira uses transitions + JQL; Linear uses state IDs + structured filters. Don't merge these schemas — the upstream APIs are genuinely different.
- Event publishing: only Jira accepts `eventBus` and emits `NewJiraIssueEvent` from its issue-watch loop. Linear has no equivalent feature today.
- Health column extras: Linear's `linear_configs` row carries an `org_slug` captured from successful probes; Jira's row does not.
---
## Maintaining This File
@@ -332,4 +357,4 @@ This file is read by AI coding agents (Claude Code via `CLAUDE.md` symlink, Code
---
**Last Updated**: 2026-03-05
**Last Updated**: 2026-05-01
+3 -83
View File
@@ -2,13 +2,10 @@ 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"
)
@@ -112,83 +109,6 @@ func TestMessageCreatorAdapter_StructFields(t *testing.T) {
}
}
// 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")
}
}
// jiraSecretAdapter Set/Exists branching is tested in
// internal/integrations/secretadapter/secretadapter_test.go now that the
// upsert helper lives there.
+28 -103
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"strings"
"go.uber.org/zap"
@@ -15,6 +14,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/integrations/secretadapter"
"github.com/kandev/kandev/internal/jira"
"github.com/kandev/kandev/internal/linear"
promptservice "github.com/kandev/kandev/internal/prompts/service"
@@ -84,30 +84,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
buildAgentProfileMatcher(repos),
)
// Initialize GitHub service
secretsAdapter := &githubSecretAdapter{store: repos.Secrets}
githubSvc, _, err := github.Provide(dbPool.Writer(), dbPool.Reader(), secretsAdapter, eventBus, log)
if err != nil {
log.Warn("GitHub service initialization failed (non-fatal)", zap.Error(err))
}
if githubSvc != nil {
// Wire secret manager for token configuration
githubSvc.SetSecretManager(secretsAdapter)
}
// Initialize JIRA service
jiraSecrets := &jiraSecretAdapter{store: repos.Secrets}
jiraSvc, _, jiraErr := jira.Provide(dbPool.Writer(), dbPool.Reader(), jiraSecrets, eventBus, log)
if jiraErr != nil {
log.Warn("JIRA service initialization failed (non-fatal)", zap.Error(jiraErr))
}
// Initialize Linear service
linearSecrets := &linearSecretAdapter{store: repos.Secrets}
linearSvc, _, linearErr := linear.Provide(dbPool.Writer(), dbPool.Reader(), linearSecrets, log)
if linearErr != nil {
log.Warn("Linear service initialization failed (non-fatal)", zap.Error(linearErr))
}
githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log)
jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log)
linearSvc := initLinearService(dbPool, repos.Secrets, log)
return &Services{
Task: taskSvc,
@@ -232,95 +211,41 @@ 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)
// initGitHubService wires up the GitHub integration. Failures are non-fatal:
// the rest of the backend still boots without GitHub configured.
func initGitHubService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *github.Service {
adapter := &githubSecretAdapter{store: secretsStore}
svc, _, err := github.Provide(dbPool.Writer(), dbPool.Reader(), adapter, eventBus, log)
if err != nil {
return err
log.Warn("GitHub service initialization failed (non-fatal)", zap.Error(err))
}
if exists {
return a.store.Update(ctx, id, &secrets.UpdateSecretRequest{Value: &value})
if svc != nil {
// GitHub takes both a SecretProvider (read-only) and a SecretManager
// (mutating) — same adapter satisfies both interfaces, but the
// service needs the mutating one wired explicitly.
svc.SetSecretManager(adapter)
}
return a.store.Create(ctx, &secrets.SecretWithValue{
Secret: secrets.Secret{ID: id, Name: name},
Value: value,
})
return svc
}
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)
// initJiraService wires up the Jira integration. Failures are non-fatal.
func initJiraService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *jira.Service {
svc, _, err := jira.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log)
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
log.Warn("JIRA service initialization failed (non-fatal)", zap.Error(err))
}
return true, nil
return svc
}
// linearSecretAdapter adapts secrets.SecretStore to linear.SecretStore. Linear
// stores its API key keyed by "linear:{workspaceID}:token". The adapter mirrors
// the upsert-onto-create/update translation that jiraSecretAdapter does.
type linearSecretAdapter struct {
store secrets.SecretStore
}
func (a *linearSecretAdapter) Reveal(ctx context.Context, id string) (string, error) {
return a.store.Reveal(ctx, id)
}
func (a *linearSecretAdapter) Set(ctx context.Context, id, name, value string) error {
exists, err := a.Exists(ctx, id)
// initLinearService wires up the Linear integration. Failures are non-fatal.
// Linear doesn't publish events today so it doesn't take an eventBus —
// adding one would be speculative until a feature actually needs it.
func initLinearService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *linear.Service {
svc, _, err := linear.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), log)
if err != nil {
return err
log.Warn("Linear service initialization failed (non-fatal)", zap.Error(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 *linearSecretAdapter) Delete(ctx context.Context, id string) error {
return a.store.Delete(ctx, id)
}
func (a *linearSecretAdapter) Exists(ctx context.Context, id string) (bool, error) {
_, err := a.store.Get(ctx, id)
if err != nil {
if strings.HasPrefix(err.Error(), "secret not found:") {
return false, nil
}
return false, err
}
return true, nil
return svc
}
// workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface.
@@ -0,0 +1,133 @@
// Package healthpoll provides a reusable background loop that probes the
// stored credentials of every configured workspace for an integration on a
// fixed cadence.
//
// Integrations supply a Prober (list workspaces + record auth health for one)
// and the package owns the Start/Stop lifecycle, the immediate-probe-on-Start
// convention, and the ticker. The default cadence is 90s — short enough to
// keep session-cookie auth warm and to surface expirations promptly in the UI,
// long enough that we don't hammer the upstream API when many workspaces are
// configured.
package healthpoll
import (
"context"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// DefaultInterval is the cadence used by Poller when no Interval is set.
const DefaultInterval = 90 * time.Second
// Prober is the integration-specific behaviour the loop drives. Both methods
// must be safe to call concurrently with themselves and each other; the loop
// itself is single-threaded but Start may be invoked while a previous Stop is
// still draining.
type Prober interface {
// ListConfiguredWorkspaces returns the IDs of every workspace that has
// credentials stored for this integration.
ListConfiguredWorkspaces(ctx context.Context) ([]string, error)
// RecordAuthHealth probes one workspace's credentials and persists the
// result on the integration's config row. Errors are intentionally not
// returned: this is a best-effort health signal, never the source of
// truth for callers, so the implementation handles its own logging.
RecordAuthHealth(ctx context.Context, workspaceID string)
}
// Poller drives Prober.RecordAuthHealth on a fixed cadence.
type Poller struct {
prober Prober
logger *logger.Logger
name string // appears in log lines, e.g. "jira", "linear"
interval time.Duration
// mu guards started/cancel/wg against concurrent Start/Stop calls.
mu sync.Mutex
cancel context.CancelFunc
wg sync.WaitGroup
started bool
}
// New returns a poller using the default 90s cadence.
func New(name string, prober Prober, log *logger.Logger) *Poller {
return &Poller{prober: prober, logger: log, name: name, interval: DefaultInterval}
}
// SetInterval overrides the default cadence. Must be called before Start.
func (p *Poller) SetInterval(d time.Duration) {
p.mu.Lock()
p.interval = d
p.mu.Unlock()
}
// 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.prober == nil {
return
}
p.started = true
ctx, p.cancel = context.WithCancel(ctx)
p.wg.Add(1)
go p.loop(ctx)
p.logger.Info(p.name + " 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(p.name + " 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)
}
}
}
// probeAll runs one probe pass across every configured workspace. Used by the
// loop and by in-package tests that want to drive a probe deterministically.
func (p *Poller) probeAll(ctx context.Context) {
ids, err := p.prober.ListConfiguredWorkspaces(ctx)
if err != nil {
p.logger.Warn(p.name+" poller: list workspaces failed", zap.Error(err))
return
}
for _, id := range ids {
if ctx.Err() != nil {
return
}
p.prober.RecordAuthHealth(ctx, id)
}
}
@@ -0,0 +1,148 @@
package healthpoll
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"github.com/kandev/kandev/internal/common/logger"
)
type fakeProber struct {
mu sync.Mutex
listFn func(ctx context.Context) ([]string, error)
probed []string
}
func (f *fakeProber) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) {
if f.listFn != nil {
return f.listFn(ctx)
}
return nil, nil
}
func (f *fakeProber) RecordAuthHealth(_ context.Context, workspaceID string) {
f.mu.Lock()
f.probed = append(f.probed, workspaceID)
f.mu.Unlock()
}
func TestProbeAll_RecordsEachWorkspace(t *testing.T) {
p := &fakeProber{
listFn: func(_ context.Context) ([]string, error) {
return []string{"ws-a", "ws-b"}, nil
},
}
poller := New("test", p, logger.Default())
poller.probeAll(context.Background())
p.mu.Lock()
defer p.mu.Unlock()
if len(p.probed) != 2 || p.probed[0] != "ws-a" || p.probed[1] != "ws-b" {
t.Errorf("expected [ws-a ws-b], got %v", p.probed)
}
}
func TestProbeAll_ListError_DoesNotPanic(t *testing.T) {
p := &fakeProber{
listFn: func(_ context.Context) ([]string, error) {
return nil, errors.New("db down")
},
}
poller := New("test", p, logger.Default())
poller.probeAll(context.Background()) // expect: silent recovery + warning log
p.mu.Lock()
defer p.mu.Unlock()
if len(p.probed) != 0 {
t.Errorf("no probes should run on list error, got %v", p.probed)
}
}
func TestProbeAll_StopsWhenContextCancelled(t *testing.T) {
p := &fakeProber{
listFn: func(_ context.Context) ([]string, error) {
return []string{"ws-a", "ws-b", "ws-c"}, nil
},
}
poller := New("test", p, logger.Default())
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before the loop runs
poller.probeAll(ctx)
p.mu.Lock()
defer p.mu.Unlock()
if len(p.probed) != 0 {
t.Errorf("expected no probes after cancel, got %v", p.probed)
}
}
func TestStart_RunsImmediateProbe(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
p := &fakeProber{
listFn: func(_ context.Context) ([]string, error) {
return []string{"ws-1"}, nil
},
}
poller := New("test", p, logger.Default())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
poller.Start(ctx)
defer poller.Stop()
// synctest.Wait advances fake time until the spawned goroutine
// finishes its immediate-on-Start probe and parks on the ticker.
synctest.Wait()
p.mu.Lock()
defer p.mu.Unlock()
if len(p.probed) == 0 {
t.Error("Start did not run an immediate probe")
}
})
}
func TestStart_IsIdempotent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var calls int32
p := &fakeProber{
listFn: func(_ context.Context) ([]string, error) {
atomic.AddInt32(&calls, 1)
return []string{"ws-1"}, nil
},
}
poller := New("test", p, logger.Default())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
poller.Start(ctx)
poller.Start(ctx) // second call must be a no-op
// Wait for the immediate-on-Start probe pass to finish. If the
// second Start spawned a parallel loop we'd see two list calls.
synctest.Wait()
poller.Stop()
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("expected exactly 1 list call from initial probe, got %d", got)
}
})
}
func TestStop_BeforeStart_IsNoOp(t *testing.T) {
poller := New("test", &fakeProber{}, logger.Default())
poller.Stop() // must not block or panic
}
func TestStop_NilProber_IsNoOp(t *testing.T) {
poller := New("test", nil, logger.Default())
poller.Start(context.Background()) // nil prober: no goroutine spawned
poller.Stop() // must not block
}
@@ -0,0 +1,72 @@
// Package secretadapter wraps a global secrets.SecretStore so per-integration
// services (jira, linear, future) can use a small, upsert-style API without
// each one re-implementing the Create-or-Update fallback or the
// "secret not found:" string-prefix check.
//
// A single Adapter satisfies any integration's local SecretStore interface
// shaped as { Reveal, Set, Delete, Exists } — Go's structural typing means
// callers don't need to import this package to consume the adapter.
package secretadapter
import (
"context"
"strings"
"github.com/kandev/kandev/internal/secrets"
)
// Adapter exposes upsert-style access on top of secrets.SecretStore.
type Adapter struct {
store secrets.SecretStore
}
// New returns an adapter backed by the given store.
func New(store secrets.SecretStore) *Adapter {
return &Adapter{store: store}
}
// Reveal returns the decrypted value for id.
func (a *Adapter) Reveal(ctx context.Context, id string) (string, error) {
return a.store.Reveal(ctx, id)
}
// Set upserts the secret value: tries Update first, falls back to Create
// when the secret does not yet exist.
func (a *Adapter) Set(ctx context.Context, id, name, value string) error {
// Detect existence via Exists (which inspects the "secret not found:"
// prefix) instead of treating any Get error as "not found": a transient
// DB error on an existing row would otherwise turn 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,
})
}
// Delete removes the secret with the given id.
func (a *Adapter) Delete(ctx context.Context, id string) error {
return a.store.Delete(ctx, id)
}
// Exists reports whether a secret with id exists. Returns (false, nil) when
// the row is absent, and (false, err) on any other error so callers can
// distinguish "not configured" from a backend outage.
func (a *Adapter) Exists(ctx context.Context, id string) (bool, error) {
_, err := a.store.Get(ctx, id)
if err != nil {
// secrets layer reports "not found" via fmt.Errorf with the
// "secret not found:" prefix; treat that as the absence case.
if strings.HasPrefix(err.Error(), "secret not found:") {
return false, nil
}
return false, err
}
return true, nil
}
@@ -0,0 +1,159 @@
package secretadapter
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/secrets"
)
// fakeStore is a minimal in-memory secrets.SecretStore for adapter tests. We
// only implement the methods the adapter actually calls; everything else
// fails the test if invoked, which catches accidental dependency creep.
type fakeStore struct {
rows map[string]string
getErr error
failOps map[string]error
}
func newFakeStore() *fakeStore {
return &fakeStore{rows: map[string]string{}, failOps: map[string]error{}}
}
func (f *fakeStore) Create(_ context.Context, s *secrets.SecretWithValue) error {
if err := f.failOps["create"]; err != nil {
return err
}
if _, exists := f.rows[s.ID]; exists {
return errors.New("constraint violation")
}
f.rows[s.ID] = s.Value
return nil
}
func (f *fakeStore) Get(_ context.Context, id string) (*secrets.Secret, error) {
if f.getErr != nil {
return nil, f.getErr
}
if _, ok := f.rows[id]; !ok {
return nil, errors.New("secret not found: " + id)
}
return &secrets.Secret{ID: id}, nil
}
func (f *fakeStore) Reveal(_ context.Context, id string) (string, error) {
v, ok := f.rows[id]
if !ok {
return "", errors.New("secret not found: " + id)
}
return v, nil
}
func (f *fakeStore) Update(_ context.Context, id string, req *secrets.UpdateSecretRequest) error {
if err := f.failOps["update"]; err != nil {
return err
}
if _, ok := f.rows[id]; !ok {
return errors.New("secret not found: " + id)
}
if req.Value != nil {
f.rows[id] = *req.Value
}
return nil
}
func (f *fakeStore) Delete(_ context.Context, id string) error {
delete(f.rows, id)
return nil
}
func (f *fakeStore) List(_ context.Context) ([]*secrets.SecretListItem, error) {
return nil, errors.New("not implemented")
}
func (f *fakeStore) Close() error { return nil }
func TestAdapter_Set_CreatesWhenMissing(t *testing.T) {
store := newFakeStore()
a := New(store)
if err := a.Set(context.Background(), "k1", "name", "v1"); err != nil {
t.Fatalf("Set: %v", err)
}
if got := store.rows["k1"]; got != "v1" {
t.Errorf("expected value v1, got %q", got)
}
}
func TestAdapter_Set_UpdatesWhenPresent(t *testing.T) {
store := newFakeStore()
store.rows["k1"] = "old"
a := New(store)
if err := a.Set(context.Background(), "k1", "name", "new"); err != nil {
t.Fatalf("Set: %v", err)
}
if got := store.rows["k1"]; got != "new" {
t.Errorf("expected value new, got %q", got)
}
}
func TestAdapter_Exists_AbsenceVsError(t *testing.T) {
store := newFakeStore()
a := New(store)
got, err := a.Exists(context.Background(), "missing")
if err != nil || got {
t.Errorf("missing: want (false, nil), got (%v, %v)", got, err)
}
store.rows["here"] = "x"
got, err = a.Exists(context.Background(), "here")
if err != nil || !got {
t.Errorf("present: want (true, nil), got (%v, %v)", got, err)
}
store.getErr = errors.New("db is down")
got, err = a.Exists(context.Background(), "anything")
if err == nil || got {
t.Errorf("transient error: want (false, err), got (%v, %v)", got, err)
}
}
func TestAdapter_Set_DoesNotMaskTransientError(t *testing.T) {
store := newFakeStore()
store.rows["k1"] = "v1"
store.getErr = errors.New("db is down")
a := New(store)
// A transient Get error must surface, not silently fall through to Create.
err := a.Set(context.Background(), "k1", "name", "new")
if err == nil {
t.Fatal("expected error from transient Get failure, got nil")
}
}
func TestAdapter_Reveal_DelegatesToStore(t *testing.T) {
store := newFakeStore()
store.rows["k1"] = "secret"
a := New(store)
got, err := a.Reveal(context.Background(), "k1")
if err != nil || got != "secret" {
t.Errorf("got (%q, %v), want (\"secret\", nil)", got, err)
}
}
func TestAdapter_Delete_DelegatesToStore(t *testing.T) {
store := newFakeStore()
store.rows["k1"] = "v"
a := New(store)
if err := a.Delete(context.Background(), "k1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, ok := store.rows["k1"]; ok {
t.Error("Delete did not remove the row")
}
}
+41 -52
View File
@@ -8,15 +8,9 @@ import (
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/integrations/healthpoll"
)
// 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
// defaultIssuePollTickInterval is how often the issue-watch loop wakes up to
// look at every enabled watcher. The actual JQL is only re-run for a watcher
// when its per-watch `PollIntervalSeconds` has elapsed since `LastPolledAt`,
@@ -27,30 +21,35 @@ const defaultIssuePollTickInterval = 60 * time.Second
// Poller drives two background loops sharing a single Service:
// - auth health: probes stored credentials so the UI can show connect status.
// Delegated to internal/integrations/healthpoll for the loop semantics.
// - issue watches: runs each enabled watcher's JQL and emits NewJiraIssueEvent
// for every matching ticket the orchestrator hasn't yet seen.
// for every matching ticket the orchestrator hasn't yet seen. Jira-specific
// and stays local.
//
// Both loops are cancelled together via Stop.
type Poller struct {
service *Service
logger *logger.Logger
authInterval time.Duration
auth *healthpoll.Poller
issueInterval time.Duration
issueTickHook func() // tests use this to observe each issue-watch tick.
// mu guards started/cancel/wg against concurrent Start/Stop calls.
mu sync.Mutex
cancel context.CancelFunc
wg sync.WaitGroup
started bool
mu sync.Mutex
cancelIssueLoop context.CancelFunc
wg sync.WaitGroup
started bool
}
// NewPoller returns a poller using the default cadences.
func NewPoller(svc *Service, log *logger.Logger) *Poller {
if svc == nil {
return nil
}
return &Poller{
service: svc,
logger: log,
authInterval: defaultAuthPollInterval,
auth: healthpoll.New("jira", svcProber{svc}, log),
issueInterval: defaultIssuePollTickInterval,
}
}
@@ -65,55 +64,47 @@ func (p *Poller) SetIssueTickHook(fn func()) {
}
// Start launches both background loops. Calling Start more than once without
// Stop is a no-op.
// Stop is a no-op. A nil receiver is also a no-op so callers don't have to
// nil-check around `NewPoller(nil, log).Start(ctx)` patterns.
func (p *Poller) Start(ctx context.Context) {
if p == nil {
return
}
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(2)
go p.loop(ctx)
go p.issueWatchLoop(ctx)
p.logger.Info("Jira poller started")
issueCtx, cancel := context.WithCancel(ctx)
p.cancelIssueLoop = cancel
p.auth.Start(ctx)
p.wg.Add(1)
go p.issueWatchLoop(issueCtx)
}
// Stop cancels the loop and waits for it to drain.
// Stop cancels both loops and waits for them to drain. A nil receiver is a
// no-op so callers using the `defer p.Stop()` pattern alongside a possibly-
// nil `NewPoller(svc, log)` don't have to nil-check.
func (p *Poller) Stop() {
if p == nil {
return
}
p.mu.Lock()
if !p.started {
p.mu.Unlock()
return
}
cancel := p.cancel
cancel := p.cancelIssueLoop
p.mu.Unlock()
if cancel != nil {
cancel()
}
p.auth.Stop()
p.wg.Wait()
p.mu.Lock()
p.started = false
p.mu.Unlock()
p.logger.Info("Jira 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.authInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.probeAll(ctx)
}
}
}
// issueWatchLoop drives the periodic JQL-poll → publish-event flow. Unlike
@@ -191,16 +182,14 @@ func (p *Poller) fireIssueTickHook() {
}
}
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)
}
// svcProber adapts *Service to healthpoll.Prober without leaking the shared
// interface into Service's public API.
type svcProber struct{ svc *Service }
func (s svcProber) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) {
return s.svc.Store().ListConfiguredWorkspaces(ctx)
}
func (s svcProber) RecordAuthHealth(ctx context.Context, workspaceID string) {
s.svc.RecordAuthHealth(ctx, workspaceID)
}
+41 -97
View File
@@ -3,17 +3,17 @@ package jira
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"testing/synctest"
"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.
// pollerFixture wires a Service against a real in-memory store and fake
// client/secret store. Loop semantics are tested in
// internal/integrations/healthpoll; the cases here exercise the jira-specific
// integration of the loop with Service.RecordAuthHealth (error preservation,
// LastCheckedAt) and the Start/Stop wiring.
type pollerFixture struct {
store *Store
secrets *fakeSecretStore
@@ -37,9 +37,9 @@ func newPollerFixture(t *testing.T) *pollerFixture {
}
// saveConfig persists a workspace config directly via the store + secret
// fakes. We deliberately avoid Service.SetConfig here because it now fires an
// fakes. We deliberately avoid Service.SetConfig here because it fires an
// async auth probe in a goroutine — fine for production but it would race
// against the deterministic `probeAll` calls these tests make.
// against the deterministic RecordAuthHealth calls these tests make.
func (f *pollerFixture) saveConfig(t *testing.T, workspaceID, secret string) {
t.Helper()
ctx := context.Background()
@@ -57,14 +57,14 @@ func (f *pollerFixture) saveConfig(t *testing.T, workspaceID, secret string) {
}
}
func TestPoller_ProbeAll_RecordsSuccess(t *testing.T) {
func TestService_RecordAuthHealth_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())
f.svc.RecordAuthHealth(context.Background(), "ws-1")
cfg, _ := f.store.GetConfig(context.Background(), "ws-1")
if cfg == nil {
@@ -81,7 +81,7 @@ func TestPoller_ProbeAll_RecordsSuccess(t *testing.T) {
}
}
func TestPoller_ProbeAll_RecordsFailure(t *testing.T) {
func TestService_RecordAuthHealth_RecordsFailure(t *testing.T) {
f := newPollerFixture(t)
f.saveConfig(t, "ws-1", "tok")
f.client.testAuthFn = func() (*TestConnectionResult, error) {
@@ -90,7 +90,7 @@ func TestPoller_ProbeAll_RecordsFailure(t *testing.T) {
return &TestConnectionResult{OK: false, Error: "step-up auth required"}, nil
}
f.poller.probeAll(context.Background())
f.svc.RecordAuthHealth(context.Background(), "ws-1")
cfg, _ := f.store.GetConfig(context.Background(), "ws-1")
if cfg.LastOk {
@@ -101,14 +101,14 @@ func TestPoller_ProbeAll_RecordsFailure(t *testing.T) {
}
}
func TestPoller_ProbeAll_ClientError(t *testing.T) {
func TestService_RecordAuthHealth_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())
f.svc.RecordAuthHealth(context.Background(), "ws-1")
cfg, _ := f.store.GetConfig(context.Background(), "ws-1")
if cfg.LastOk {
@@ -119,90 +119,34 @@ func TestPoller_ProbeAll_ClientError(t *testing.T) {
}
}
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_Start_ProbesEachConfiguredWorkspace(t *testing.T) {
// Smoke test: confirms the prober adapter actually wires
// Service.Store().ListConfiguredWorkspaces → Service.RecordAuthHealth
// when the loop is started, end-to-end. Wrapped in synctest so the
// goroutine the poller spawns runs deterministically against fake time
// instead of relying on a wall-clock deadline.
synctest.Test(t, func(t *testing.T) {
f := newPollerFixture(t)
f.saveConfig(t, "ws-a", "tok-a")
f.saveConfig(t, "ws-b", "tok-b")
probed := map[string]bool{}
f.client.testAuthFn = func() (*TestConnectionResult, error) {
return &TestConnectionResult{OK: true}, nil
}
}
}
f.svc.SetProbeHook(func(workspaceID string) {
probed[workspaceID] = true
})
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:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f.poller.Start(ctx)
defer f.poller.Stop()
// Wait for the immediate-on-Start probe pass to finish.
synctest.Wait()
if !probed["ws-a"] || !probed["ws-b"] {
t.Errorf("expected both ws-a and ws-b probed, got %v", probed)
}
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)
}
})
}
+24 -65
View File
@@ -2,95 +2,54 @@ package linear
import (
"context"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/integrations/healthpoll"
)
// defaultAuthPollInterval is how often the auth-health poller probes each
// configured workspace.
const defaultAuthPollInterval = 90 * time.Second
// Poller probes the stored Linear credentials of every configured workspace on
// a fixed cadence and persists the result on the LinearConfig row.
// the shared 90s auth-health cadence. The actual loop lives in
// internal/integrations/healthpoll; this type exists so callers can keep the
// familiar `linear.NewPoller(...).Start(ctx)` shape without depending on the
// shared package directly.
type Poller struct {
service *Service
logger *logger.Logger
interval time.Duration
mu sync.Mutex
cancel context.CancelFunc
wg sync.WaitGroup
started bool
inner *healthpoll.Poller
}
// NewPoller returns a poller that uses the default 90s cadence.
// NewPoller returns a poller using the default 90s cadence. Returns nil when
// svc is nil so the caller's nil-check still works after the indirection.
func NewPoller(svc *Service, log *logger.Logger) *Poller {
return &Poller{service: svc, logger: log, interval: defaultAuthPollInterval}
if svc == nil {
return nil
}
return &Poller{inner: healthpoll.New("linear", svcProber{svc}, log)}
}
// 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 {
if p == nil || p.inner == nil {
return
}
p.started = true
ctx, p.cancel = context.WithCancel(ctx)
p.wg.Add(1)
go p.loop(ctx)
p.logger.Info("Linear auth poller started")
p.inner.Start(ctx)
}
// Stop cancels the loop and waits for it to drain.
func (p *Poller) Stop() {
p.mu.Lock()
if !p.started {
p.mu.Unlock()
if p == nil || p.inner == nil {
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("Linear auth poller stopped")
p.inner.Stop()
}
func (p *Poller) loop(ctx context.Context) {
defer p.wg.Done()
p.probeAll(ctx)
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.probeAll(ctx)
}
}
// svcProber adapts *Service to healthpoll.Prober without leaking the shared
// interface into Service's public API.
type svcProber struct{ svc *Service }
func (s svcProber) ListConfiguredWorkspaces(ctx context.Context) ([]string, error) {
return s.svc.Store().ListConfiguredWorkspaces(ctx)
}
func (p *Poller) probeAll(ctx context.Context) {
ids, err := p.service.Store().ListConfiguredWorkspaces(ctx)
if err != nil {
p.logger.Warn("linear poller: list workspaces failed", zap.Error(err))
return
}
for _, id := range ids {
if ctx.Err() != nil {
return
}
p.service.RecordAuthHealth(ctx, id)
}
func (s svcProber) RecordAuthHealth(ctx context.Context, workspaceID string) {
s.svc.RecordAuthHealth(ctx, workspaceID)
}
+39 -86
View File
@@ -3,13 +3,17 @@ package linear
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"testing/synctest"
"github.com/kandev/kandev/internal/common/logger"
)
// pollerFixture wires a Service against a real in-memory store and fake
// client/secret store. The auth-loop semantics live in
// internal/integrations/healthpoll; the tests here cover the linear-specific
// integration of the loop with Service.RecordAuthHealth (org_slug capture,
// error preservation) and the Start/Stop smoke wiring.
type pollerFixture struct {
store *Store
secrets *fakeSecretStore
@@ -49,14 +53,14 @@ func (f *pollerFixture) saveConfig(t *testing.T, workspaceID, secret string) {
}
}
func TestPoller_ProbeAll_RecordsSuccess(t *testing.T) {
func TestService_RecordAuthHealth_RecordsSuccess(t *testing.T) {
f := newPollerFixture(t)
f.saveConfig(t, "ws-1", "tok")
f.client.testAuthFn = func() (*TestConnectionResult, error) {
return &TestConnectionResult{OK: true, OrgSlug: "acme"}, nil
}
f.poller.probeAll(context.Background())
f.svc.RecordAuthHealth(context.Background(), "ws-1")
cfg, _ := f.store.GetConfig(context.Background(), "ws-1")
if cfg == nil {
@@ -70,14 +74,14 @@ func TestPoller_ProbeAll_RecordsSuccess(t *testing.T) {
}
}
func TestPoller_ProbeAll_RecordsFailure(t *testing.T) {
func TestService_RecordAuthHealth_RecordsFailure(t *testing.T) {
f := newPollerFixture(t)
f.saveConfig(t, "ws-1", "tok")
f.client.testAuthFn = func() (*TestConnectionResult, error) {
return &TestConnectionResult{OK: false, Error: "401 unauthorized"}, nil
}
f.poller.probeAll(context.Background())
f.svc.RecordAuthHealth(context.Background(), "ws-1")
cfg, _ := f.store.GetConfig(context.Background(), "ws-1")
if cfg.LastOk {
@@ -88,14 +92,14 @@ func TestPoller_ProbeAll_RecordsFailure(t *testing.T) {
}
}
func TestPoller_ProbeAll_ClientError(t *testing.T) {
func TestService_RecordAuthHealth_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())
f.svc.RecordAuthHealth(context.Background(), "ws-1")
cfg, _ := f.store.GetConfig(context.Background(), "ws-1")
if cfg.LastOk {
@@ -103,85 +107,34 @@ func TestPoller_ProbeAll_ClientError(t *testing.T) {
}
}
func TestPoller_ProbeAll_NoWorkspaces(t *testing.T) {
f := newPollerFixture(t)
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_Start_ProbesEachConfiguredWorkspace(t *testing.T) {
// Smoke test: confirms the prober adapter actually wires
// Service.Store().ListConfiguredWorkspaces → Service.RecordAuthHealth
// when the loop is started, end-to-end. Wrapped in synctest so the
// goroutine the poller spawns runs deterministically against fake time
// instead of relying on a wall-clock deadline.
synctest.Test(t, func(t *testing.T) {
f := newPollerFixture(t)
f.saveConfig(t, "ws-a", "tok-a")
f.saveConfig(t, "ws-b", "tok-b")
probed := map[string]bool{}
f.client.testAuthFn = func() (*TestConnectionResult, error) {
return &TestConnectionResult{OK: true}, nil
}
}
}
f.svc.SetProbeHook(func(workspaceID string) {
probed[workspaceID] = true
})
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:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f.poller.Start(ctx)
defer f.poller.Stop()
// Wait for the immediate-on-Start probe pass to finish.
synctest.Wait()
if !probed["ws-a"] || !probed["ws-b"] {
t.Errorf("expected both ws-a and ws-b probed, got %v", probed)
}
return &TestConnectionResult{OK: true}, nil
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f.poller.Start(ctx)
defer f.poller.Stop()
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)
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)
}
})
}
+1 -1
View File
@@ -17,7 +17,7 @@ import {
} from "@kandev/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { PageTopbar } from "@/components/page-topbar";
import { useLinearAvailable } from "@/components/linear/use-linear-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import {
formatRelative,
LinearErrorMessage,
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { IconLockExclamation } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
// Drops support URLs upstream APIs sometimes inline into error response
// bodies — they're noise once the user has a clear CTA. Shared because both
// jira and linear surface verbose, URL-laden messages.
const URL_RE = /\bhttps?:\/\/\S+/g;
export function cleanIntegrationErrorMessage(error: string): string {
return error.replace(URL_RE, "").replace(/\s+/g, " ").trim();
}
export type IntegrationAuthErrorMessageProps = {
error: string;
// Display name shown in the headline / button label, e.g. "Jira", "Linear".
name: string;
// Where the "Reconnect" button takes the user; null/undefined falls back
// to the global settings page.
reconnectHref: string;
// Integration-specific check for whether the error is an auth failure
// (each integration's API formats status codes differently).
isAuthError: (error: string) => boolean;
// Long-form copy shown under the headline in the non-compact variant.
// Integration-specific because the user-facing remediation differs
// (session vs API key, etc.).
authErrorBody: string;
// Inline variant for cases where context is already rendered above.
compact?: boolean;
};
// IntegrationAuthErrorMessage renders the standard error UI shown when an
// integration request fails. Auth failures get a Reconnect CTA pointing at
// the workspace's settings page; everything else just shows the cleaned
// upstream message.
export function IntegrationAuthErrorMessage({
error,
name,
reconnectHref,
isAuthError,
authErrorBody,
compact,
}: IntegrationAuthErrorMessageProps) {
const isAuth = isAuthError(error);
if (compact) {
return (
<div className="flex items-center gap-3 text-sm">
<span className={isAuth ? "text-muted-foreground" : "text-destructive"}>
{isAuth ? `${name} authentication required.` : cleanIntegrationErrorMessage(error)}
</span>
{isAuth && (
<Button asChild size="sm" variant="outline" className="cursor-pointer h-7 text-xs">
<Link href={reconnectHref}>Reconnect {name}</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">{name} authentication required</h2>
<p className="text-sm text-muted-foreground">{authErrorBody}</p>
</div>
<Button asChild size="sm" className="cursor-pointer">
<Link href={reconnectHref}>Reconnect {name}</Link>
</Button>
</>
) : (
<p className="text-sm text-destructive">{cleanIntegrationErrorMessage(error)}</p>
)}
</div>
);
}
@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { IconAlertTriangle, IconCheck } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
// AuthHealth captures everything every integration's config row tells us
// about the most-recent backend health probe. Each integration's settings
// page builds one of these from its own config shape.
export type IntegrationAuthHealth = {
ok: boolean;
error: string;
checkedAt: Date | null;
};
// 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>
);
}
// IntegrationAuthStatusBanner renders the standard authenticated /
// authentication-failed / waiting alerts shown on every integration's
// settings page. Returns null when health is null (config not yet loaded or
// no secret configured) so the caller doesn't have to guard.
export function IntegrationAuthStatusBanner({ health }: { health: IntegrationAuthHealth | 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>
);
}
@@ -16,11 +16,11 @@ vi.mock("@/hooks/domains/github/use-github-status", () => ({
useGitHubStatus: useGitHubStatusMock,
}));
vi.mock("@/components/jira/my-jira/use-jira-availability", () => ({
vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({
useJiraAvailable: useJiraAvailableMock,
}));
vi.mock("@/components/linear/use-linear-availability", () => ({
vi.mock("@/hooks/domains/linear/use-linear-availability", () => ({
useLinearAvailable: useLinearAvailableMock,
}));
@@ -11,8 +11,8 @@ import {
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { IconBrandGithub, IconHexagon, IconPlugConnected, IconTicket } from "@tabler/icons-react";
import { useJiraAvailable } from "@/components/jira/my-jira/use-jira-availability";
import { useLinearAvailable } from "@/components/linear/use-linear-availability";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
import type { GitHubStatus } from "@/lib/types/github";
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, act, waitFor, cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});
// Stub the Radix-backed Popover/Tooltip primitives so children render inline
// (no portal, no measurement gymnastics) and the popover's open/close is
// driven by ValidatedPopover's own onOpenChange callback that we surface via
// a parent-controlled close button.
vi.mock("@kandev/ui/popover", () => {
return {
Popover: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
children: React.ReactNode;
}) => (
<div data-testid="popover" data-open={open}>
{children}
<button data-testid="popover-close" type="button" onClick={() => onOpenChange(false)}>
close
</button>
</div>
),
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
};
});
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import { ValidatedPopover } from "./validated-popover";
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function renderHarness(overrides?: { fetch?: (key: string) => Promise<string> }) {
const onSuccess = vi.fn();
const result = render(
<ValidatedPopover
triggerStyle="ghost-icon"
triggerIcon={<span>icon</span>}
triggerAriaLabel="trigger"
tooltip="tip"
headline="Headline"
placeholder="paste here"
extractKey={(raw) => (raw.trim() ? raw.trim() : null)}
validationHint="hint"
fetch={overrides?.fetch ?? (async (k: string) => k)}
onSuccess={onSuccess}
submitLabel="Submit"
submittingLabel="Submitting..."
/>,
);
return { ...result, onSuccess };
}
describe("ValidatedPopover", () => {
it("close-while-loading resets loading state and ignores the late resolution", async () => {
const d = deferred<string>();
const fetchSpy = vi.fn(async () => d.promise);
const { onSuccess } = renderHarness({ fetch: fetchSpy });
// Type a key + submit
fireEvent.change(screen.getByPlaceholderText("paste here"), { target: { value: "KEY-1" } });
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
// Mid-flight: submitting label visible, button disabled.
await waitFor(() => {
const submitting = screen.getByRole("button", { name: "Submitting..." }) as HTMLButtonElement;
expect(submitting.disabled).toBe(true);
});
// User dismisses the popover before fetch resolves.
fireEvent.click(screen.getByTestId("popover-close"));
// Loading must reset on close so the next open starts clean.
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Submitting..." })).toBeNull();
});
// Late resolution must not call onSuccess (the submission token was
// invalidated when we closed) and must not bring the popover back.
await act(async () => {
d.resolve("late");
await Promise.resolve();
});
expect(onSuccess).not.toHaveBeenCalled();
});
it("close-while-loading suppresses a late rejection rather than re-populating error", async () => {
const d = deferred<string>();
const fetchSpy = vi.fn(async () => d.promise);
renderHarness({ fetch: fetchSpy });
fireEvent.change(screen.getByPlaceholderText("paste here"), { target: { value: "KEY-1" } });
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => {
const submitting = screen.getByRole("button", { name: "Submitting..." }) as HTMLButtonElement;
expect(submitting.disabled).toBe(true);
});
fireEvent.click(screen.getByTestId("popover-close"));
// Reject after close — the in-flight error must not leak into UI state.
await act(async () => {
d.reject(new Error("boom"));
await Promise.resolve().catch(() => {});
});
// role=alert is the inline error <p> in ValidatedPopover; should be absent.
expect(screen.queryByRole("alert")).toBeNull();
});
});
@@ -0,0 +1,194 @@
"use client";
import { useCallback, useRef, useState, type ReactNode } from "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";
// ValidatedPopover is the shared trigger+popover+input+submit skeleton both
// integrations need for "paste a key/URL → fetch the entity → do something
// with it" flows (link a task, import a ticket/issue). It owns:
// - open/close + state reset on close
// - the keyed Input with autofocus + Enter-to-submit
// - validation against an integration-supplied regex
// - loading + error display
// - the disabled-while-empty / disabled-while-loading submit Button
//
// Each integration provides {icon, label, tooltip, placeholder, regex,
// fetch, onSuccess, validationHint, headline, submitLabel, submittingLabel}.
//
// Designing this as one shared skeleton both fixes the existing bug that
// JiraLinkButton was missing the clear-error-on-close behaviour LinearLinkButton
// had, and prevents future drift between the four near-identical popovers.
export type ValidatedPopoverTriggerStyle = "outline-with-label" | "ghost-icon";
export type ValidatedPopoverProps<T> = {
// Trigger button content + tooltip.
triggerStyle: ValidatedPopoverTriggerStyle;
triggerIcon: ReactNode;
triggerLabel?: string; // Required for "outline-with-label", ignored for "ghost-icon".
triggerAriaLabel?: string; // Used for "ghost-icon" since there's no visible label.
triggerDisabled?: boolean;
tooltip: string;
// PopoverContent layout.
align?: "start" | "end";
headline: string;
placeholder: string;
// Validation: extract a key from the user's input. Returning null shows the
// hint as an error. The hint typically reads "Paste a Jira ticket URL or
// key (PROJ-123)" or similar — integration-specific copy.
extractKey: (rawValue: string) => string | null;
validationHint: string;
// Async work to run after the user submits a valid key. The result is
// handed to onSuccess; throwing surfaces .message as the error string.
fetch: (key: string) => Promise<T>;
onSuccess: (key: string, result: T) => void;
// Submit button labels.
submitLabel: string; // e.g. "Link", "Import"
submittingLabel: string; // e.g. "Linking...", "Loading..."
};
function TriggerButton({
triggerStyle,
triggerIcon,
triggerLabel,
triggerAriaLabel,
triggerDisabled,
}: Pick<
ValidatedPopoverProps<unknown>,
"triggerStyle" | "triggerIcon" | "triggerLabel" | "triggerAriaLabel" | "triggerDisabled"
>) {
if (triggerStyle === "outline-with-label") {
return (
<Button
type="button"
size="sm"
variant="outline"
disabled={triggerDisabled}
className="cursor-pointer px-2 gap-1"
>
{triggerIcon}
<span className="text-xs font-medium">{triggerLabel}</span>
</Button>
);
}
return (
<Button
type="button"
variant="ghost"
size="icon"
disabled={triggerDisabled}
aria-label={triggerAriaLabel}
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-slate-400"
>
{triggerIcon}
</Button>
);
}
export function ValidatedPopover<T>(props: ValidatedPopoverProps<T>) {
const {
tooltip,
align,
headline,
placeholder,
extractKey,
validationHint,
fetch,
onSuccess,
submitLabel,
submittingLabel,
} = props;
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Submission token: bumped on every submit and on close, so an in-flight
// request whose promise resolves after the popover was closed (or
// re-opened) cannot leak loading/error state into the next session.
const submissionRef = useRef(0);
const submit = useCallback(async () => {
const key = extractKey(value.trim());
if (!key) {
setError(validationHint);
return;
}
const submission = ++submissionRef.current;
setLoading(true);
setError(null);
try {
const result = await fetch(key);
if (submission !== submissionRef.current) return;
onSuccess(key, result);
setOpen(false);
setValue("");
} catch (err) {
if (submission !== submissionRef.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (submission === submissionRef.current) setLoading(false);
}
}, [value, extractKey, validationHint, fetch, onSuccess]);
// Closing invalidates any in-flight submit (so a late rejection can't
// re-populate `error` after the user dismissed the popover) and clears
// local submit state so the next open starts clean.
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) {
submissionRef.current += 1;
setLoading(false);
setError(null);
}
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<TriggerButton {...props} />
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
<PopoverContent align={align ?? "end"} className="w-80 p-3">
<div className="space-y-2">
<div className="text-xs font-medium">{headline}</div>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
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 ? submittingLabel : submitLabel}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
+19 -94
View File
@@ -1,20 +1,11 @@
"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";
import { useJiraAvailable } from "./my-jira/use-jira-availability";
function extractKey(input: string): string | null {
const match = input.toUpperCase().match(JIRA_KEY_RE);
return match ? match[0] : null;
}
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { ValidatedPopover } from "@/components/integrations/validated-popover";
type JiraImportBarProps = {
workspaceId: string | null;
@@ -24,90 +15,24 @@ type JiraImportBarProps = {
export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBarProps) {
const available = useJiraAvailable(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 (!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]);
if (!available) return null;
if (!available || !workspaceId) return null;
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>
<ValidatedPopover
triggerStyle="ghost-icon"
triggerIcon={<IconTicket className="h-4 w-4" />}
triggerAriaLabel="Import from Jira"
triggerDisabled={disabled}
tooltip="Import from Jira ticket URL or key"
align="start"
headline="Import Jira ticket"
placeholder="PROJ-123 or paste ticket URL"
extractKey={(raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null}
validationHint="Paste a Jira ticket URL or key (PROJ-123)"
fetch={(key) => getJiraTicket(workspaceId, key)}
onSuccess={(_key, ticket) => onImport(ticket)}
submitLabel="Import"
submittingLabel="Loading..."
/>
);
}
+33 -80
View File
@@ -1,16 +1,13 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback } 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 { getJiraTicket } from "@/lib/api/domains/jira-api";
import { updateTask } from "@/lib/api/domains/kanban-api";
import { JIRA_KEY_RE } from "./jira-ticket-common";
import { useJiraAvailable } from "./my-jira/use-jira-availability";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { ValidatedPopover } from "@/components/integrations/validated-popover";
type JiraLinkButtonProps = {
taskId: string | null | undefined;
@@ -23,88 +20,44 @@ type JiraLinkButtonProps = {
// JiraTicketButton picks up the key automatically once the title is updated.
export function JiraLinkButton({ taskId, workspaceId, taskTitle }: JiraLinkButtonProps) {
const available = useJiraAvailable(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);
const buildLinkedTitle = useCallback(
(key: string) => {
// 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]);
return stripped ? `${key}: ${stripped}` : key;
},
[taskTitle],
);
if (!available || !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>
<ValidatedPopover
triggerStyle="outline-with-label"
triggerIcon={<IconLink className="h-4 w-4" />}
triggerLabel="Link Jira"
tooltip="Link this task to a Jira ticket"
headline="Link to Jira ticket"
placeholder="PROJ-123 or paste ticket URL"
extractKey={(raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null}
validationHint="Paste a Jira ticket URL or key (PROJ-123)"
// Run getJiraTicket *and* the title update inside the awaited fetch so
// a failure on either surfaces as the popover's inline error instead of
// an unhandled rejection that leaves the user thinking the link
// succeeded.
fetch={async (key) => {
const ticket = await getJiraTicket(workspaceId, key);
await updateTask(taskId, { title: buildLinkedTitle(key) });
return ticket;
}}
onSuccess={(key) => {
toast.success(`Linked to ${key}`);
}}
submitLabel="Link"
submittingLabel="Linking..."
/>
);
}
+10 -69
View File
@@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { IconTicket, IconCode, IconCheck, IconAlertTriangle } from "@tabler/icons-react";
import { IconTicket, IconCode } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
@@ -15,7 +14,12 @@ import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { TaskPresetsSection } from "@/components/jira/task-presets-section";
import { JiraIssueWatchersSection } from "@/components/jira/jira-issue-watchers-section";
import { useJiraEnabled } from "@/components/jira/my-jira/use-jira-enabled";
import { useJiraEnabled } from "@/hooks/domains/jira/use-jira-enabled";
import {
IntegrationAuthStatusBanner,
type IntegrationAuthHealth,
} from "@/components/integrations/auth-status-banner";
import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability";
import {
getJiraConfig,
setJiraConfig,
@@ -24,10 +28,6 @@ import {
} 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
@@ -262,13 +262,7 @@ function TestResultAlert({ result }: { result: TestJiraConnectionResult | null }
);
}
type AuthHealth = {
ok: boolean;
error: string;
checkedAt: Date | null;
};
function configToHealth(config: JiraConfig | null): AuthHealth | null {
function configToHealth(config: JiraConfig | null): IntegrationAuthHealth | null {
if (!config?.hasSecret) return null;
if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null };
return {
@@ -278,59 +272,6 @@ function configToHealth(config: JiraConfig | null): AuthHealth | null {
};
}
// 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;
@@ -424,7 +365,7 @@ function useJiraSettings(workspaceId: string) {
.catch(() => {
/* transient failures are fine — next tick retries */
});
}, STATUS_REFRESH_MS);
}, INTEGRATION_STATUS_REFRESH_MS);
return () => clearInterval(id);
}, [workspaceId]);
@@ -536,7 +477,7 @@ export function JiraSettings({ workspaceId }: JiraSettingsProps) {
>
<Card>
<CardContent className="space-y-4 pt-6">
<AuthStatusBanner health={s.health} />
<IntegrationAuthStatusBanner health={s.health} />
<SiteFields form={s.form} loading={s.loading} update={s.update} />
<AuthFields form={s.form} loading={s.loading} update={s.update} />
<SecretField
+10 -47
View File
@@ -1,13 +1,11 @@
"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";
import { IntegrationAuthErrorMessage } from "@/components/integrations/auth-error-message";
// 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.
@@ -154,13 +152,7 @@ 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();
}
export { cleanIntegrationErrorMessage as cleanJiraErrorMessage } from "@/components/integrations/auth-error-message";
type JiraErrorMessageProps = {
error: string;
@@ -170,43 +162,14 @@ type JiraErrorMessageProps = {
};
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>
<IntegrationAuthErrorMessage
error={error}
name="Jira"
reconnectHref={workspaceId ? `/settings/workspace/${workspaceId}/jira` : "/settings"}
isAuthError={isJiraAuthError}
authErrorBody="Your Jira session expired or needs step-up authentication. Reconnect to view this ticket."
compact={compact}
/>
);
}
@@ -1,53 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { getJiraConfig } from "@/lib/api/domains/jira-api";
import { useJiraEnabled } from "./use-jira-enabled";
// The backend poller probes Jira credentials roughly every 90s. Refreshing
// at the same cadence keeps the UI no more than ~one cycle stale.
const JIRA_STATUS_REFRESH_MS = 90_000;
// Reads the backend-recorded auth health for a workspace. Returns true only
// when a config exists, has a secret, and the most recent probe succeeded.
// Pass `undefined` to short-circuit (no fetch, no interval).
// State is tagged with the workspace it was probed for so a workspace switch
// invalidates stale results synchronously at read time — no reset needed,
// which keeps the 90s poll tick from flickering the UI between request
// dispatch and response.
type AuthState = { workspaceId: string | undefined; authed: boolean };
export function useJiraAuthed(workspaceId: string | undefined): boolean {
const [state, setState] = useState<AuthState>({ workspaceId: undefined, authed: false });
useEffect(() => {
if (!workspaceId) return;
let cancelled = false;
async function refresh() {
try {
const cfg = await getJiraConfig(workspaceId!);
if (cancelled) return;
setState({ workspaceId, authed: !!cfg?.hasSecret && !!cfg.lastOk });
} catch {
if (!cancelled) setState({ workspaceId, authed: false });
}
}
void refresh();
const id = setInterval(() => void refresh(), JIRA_STATUS_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [workspaceId]);
return state.workspaceId === workspaceId && state.authed;
}
// Combined check for showing Jira UI: the workspace toggle is on AND the
// backend reports a configured, healthy connection.
export function useJiraAvailable(workspaceId: string | null | undefined): boolean {
const ws = workspaceId ?? undefined;
// `loaded` flips true after the localStorage read settles; gating the probe
// on it avoids a wasted fetch on the first render when the toggle is off.
const { enabled, loaded } = useJiraEnabled(ws);
const authed = useJiraAuthed(enabled && loaded ? ws : undefined);
return enabled && authed;
}
@@ -1,62 +0,0 @@
"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 };
}
+19 -101
View File
@@ -1,20 +1,11 @@
"use client";
import { useCallback, useState } from "react";
import { IconHexagon } 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 { getLinearIssue } from "@/lib/api/domains/linear-api";
import type { LinearIssue } from "@/lib/types/linear";
import { LINEAR_KEY_RE } from "./linear-issue-common";
import { useLinearAvailable } from "./use-linear-availability";
function extractKey(input: string): string | null {
const match = input.toUpperCase().match(LINEAR_KEY_RE);
return match ? match[0] : null;
}
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { ValidatedPopover } from "@/components/integrations/validated-popover";
type LinearImportBarProps = {
workspaceId: string | null;
@@ -24,97 +15,24 @@ type LinearImportBarProps = {
export function LinearImportBar({ workspaceId, disabled, onImport }: LinearImportBarProps) {
const available = useLinearAvailable(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 (!workspaceId) {
setError("Select a workspace first");
return;
}
const key = extractKey(value.trim());
if (!key) {
setError("Paste a Linear issue URL or identifier (ENG-123)");
return;
}
setLoading(true);
setError(null);
try {
const issue = await getLinearIssue(workspaceId, key);
onImport(issue);
setOpen(false);
setValue("");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [workspaceId, value, onImport]);
if (!available) return null;
// Closing the popover discards any stale validation/error state so the
// next open starts clean rather than rehydrating yesterday's failure.
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setError(null);
};
if (!available || !workspaceId) return null;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label="Import from Linear"
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-slate-400"
>
<IconHexagon className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Import from Linear issue URL or identifier</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-80 p-3">
<div className="space-y-2">
<div className="text-xs font-medium">Import Linear issue</div>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="ENG-123 or paste issue 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>
<ValidatedPopover
triggerStyle="ghost-icon"
triggerIcon={<IconHexagon className="h-4 w-4" />}
triggerAriaLabel="Import from Linear"
triggerDisabled={disabled}
tooltip="Import from Linear issue URL or identifier"
align="start"
headline="Import Linear issue"
placeholder="ENG-123 or paste issue URL"
extractKey={(raw) => raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null}
validationHint="Paste a Linear issue URL or identifier (ENG-123)"
fetch={(key) => getLinearIssue(workspaceId, key)}
onSuccess={(_key, issue) => onImport(issue)}
submitLabel="Import"
submittingLabel="Loading..."
/>
);
}
@@ -1,13 +1,11 @@
"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 { getLinearIssue, setLinearIssueState } from "@/lib/api/domains/linear-api";
import type { LinearIssue, LinearStateCategory } from "@/lib/types/linear";
import { IntegrationAuthErrorMessage } from "@/components/integrations/auth-error-message";
// Matches Linear identifiers like ENG-123. Linear team keys are always
// uppercase and 1+ chars; we require a leading capital letter to avoid catching
@@ -157,11 +155,7 @@ export function isLinearAuthError(error: string): boolean {
return AUTH_STATUS_RE.test(error);
}
const URL_RE = /\bhttps?:\/\/\S+/g;
export function cleanLinearErrorMessage(error: string): string {
return error.replace(URL_RE, "").replace(/\s+/g, " ").trim();
}
export { cleanIntegrationErrorMessage as cleanLinearErrorMessage } from "@/components/integrations/auth-error-message";
type LinearErrorMessageProps = {
error: string;
@@ -170,42 +164,14 @@ type LinearErrorMessageProps = {
};
export function LinearErrorMessage({ error, workspaceId, compact }: LinearErrorMessageProps) {
const isAuth = isLinearAuthError(error);
const settingsHref = workspaceId ? `/settings/workspace/${workspaceId}/linear` : "/settings";
if (compact) {
return (
<div className="flex items-center gap-3 text-sm">
<span className={isAuth ? "text-muted-foreground" : "text-destructive"}>
{isAuth ? "Linear authentication required." : cleanLinearErrorMessage(error)}
</span>
{isAuth && (
<Button asChild size="sm" variant="outline" className="cursor-pointer h-7 text-xs">
<Link href={settingsHref}>Reconnect Linear</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">Linear authentication required</h2>
<p className="text-sm text-muted-foreground">
Your Linear API key is invalid or has been revoked. Reconnect to view this issue.
</p>
</div>
<Button asChild size="sm" className="cursor-pointer">
<Link href={settingsHref}>Reconnect Linear</Link>
</Button>
</>
) : (
<p className="text-sm text-destructive">{cleanLinearErrorMessage(error)}</p>
)}
</div>
<IntegrationAuthErrorMessage
error={error}
name="Linear"
reconnectHref={workspaceId ? `/settings/workspace/${workspaceId}/linear` : "/settings"}
isAuthError={isLinearAuthError}
authErrorBody="Your Linear API key is invalid or has been revoked. Reconnect to view this issue."
compact={compact}
/>
);
}
@@ -1,16 +1,13 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback } 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 { getLinearIssue } from "@/lib/api/domains/linear-api";
import { updateTask } from "@/lib/api/domains/kanban-api";
import { LINEAR_KEY_RE } from "./linear-issue-common";
import { useLinearAvailable } from "./use-linear-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { ValidatedPopover } from "@/components/integrations/validated-popover";
type LinearLinkButtonProps = {
taskId: string | null | undefined;
@@ -18,100 +15,44 @@ type LinearLinkButtonProps = {
taskTitle: string | undefined | null;
};
// LinearLinkButton lets the user attach a Linear issue to an existing task by
// prepending the issue identifier to its title ("ENG-123: ..."). The
// LinearIssueButton picks up the identifier automatically once the title is
// updated.
// LinearLinkButton attaches a Linear issue to an existing task by prepending
// the identifier to its title ("ENG-123: ...").
export function LinearLinkButton({ taskId, workspaceId, taskTitle }: LinearLinkButtonProps) {
const available = useLinearAvailable(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(LINEAR_KEY_RE);
if (!match) {
setError("Paste a Linear issue URL or identifier (ENG-123)");
return;
}
const key = match[0];
setLoading(true);
setError(null);
try {
await getLinearIssue(workspaceId, key);
// Strip any existing leading "ENG-123: " so re-linking replaces the
// prefix instead of stacking it.
const buildLinkedTitle = useCallback(
(key: string) => {
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]);
return stripped ? `${key}: ${stripped}` : key;
},
[taskTitle],
);
if (!available || !taskId || !workspaceId) return null;
// Closing the popover discards any stale validation/error so reopening
// starts clean.
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setError(null);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<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 Linear</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Link this task to a Linear issue</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-80 p-3">
<div className="space-y-2">
<div className="text-xs font-medium">Link to Linear issue</div>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="ENG-123 or paste issue 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>
<ValidatedPopover
triggerStyle="outline-with-label"
triggerIcon={<IconLink className="h-4 w-4" />}
triggerLabel="Link Linear"
tooltip="Link this task to a Linear issue"
headline="Link to Linear issue"
placeholder="ENG-123 or paste issue URL"
extractKey={(raw) => raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null}
validationHint="Paste a Linear issue URL or identifier (ENG-123)"
// Run getLinearIssue *and* the title update inside the awaited fetch so
// a failure on either surfaces as the popover's inline error instead of
// an unhandled rejection.
fetch={async (key) => {
const issue = await getLinearIssue(workspaceId, key);
await updateTask(taskId, { title: buildLinkedTitle(key) });
return issue;
}}
onSuccess={(key) => {
toast.success(`Linked to ${key}`);
}}
submitLabel="Link"
submittingLabel="Linking..."
/>
);
}
+10 -67
View File
@@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { IconHexagon, IconCheck, IconAlertTriangle } from "@tabler/icons-react";
import { IconHexagon } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
@@ -13,7 +12,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Switch } from "@kandev/ui/switch";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { useLinearEnabled } from "./use-linear-enabled";
import { useLinearEnabled } from "@/hooks/domains/linear/use-linear-enabled";
import {
IntegrationAuthStatusBanner,
type IntegrationAuthHealth,
} from "@/components/integrations/auth-status-banner";
import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability";
import {
getLinearConfig,
setLinearConfig,
@@ -23,10 +27,6 @@ import {
} from "@/lib/api/domains/linear-api";
import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib/types/linear";
// 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;
type FormState = {
defaultTeamKey: string;
secret: string;
@@ -130,13 +130,7 @@ function TestResultAlert({ result }: { result: TestLinearConnectionResult | null
);
}
type AuthHealth = {
ok: boolean;
error: string;
checkedAt: Date | null;
};
function configToHealth(config: LinearConfig | null): AuthHealth | null {
function configToHealth(config: LinearConfig | null): IntegrationAuthHealth | null {
if (!config?.hasSecret) return null;
if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null };
return {
@@ -146,57 +140,6 @@ function configToHealth(config: LinearConfig | null): AuthHealth | null {
};
}
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;
@@ -390,7 +333,7 @@ function useLinearSettings(workspaceId: string) {
.catch(() => {
/* transient failures are fine — next tick retries */
});
}, STATUS_REFRESH_MS);
}, INTEGRATION_STATUS_REFRESH_MS);
return () => clearInterval(id);
}, [workspaceId]);
@@ -458,7 +401,7 @@ export function LinearSettings({ workspaceId }: LinearSettingsProps) {
>
<Card>
<CardContent className="space-y-4 pt-6">
<AuthStatusBanner health={s.health} />
<IntegrationAuthStatusBanner health={s.health} />
<SecretField
form={s.form}
loading={s.loading}
@@ -1,44 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { getLinearConfig } from "@/lib/api/domains/linear-api";
import { useLinearEnabled } from "./use-linear-enabled";
const LINEAR_STATUS_REFRESH_MS = 90_000;
type AuthState = { workspaceId: string | undefined; authed: boolean };
// Reads the backend-recorded auth health for a workspace. Returns true only
// when a config exists, has a secret, and the most recent probe succeeded.
export function useLinearAuthed(workspaceId: string | undefined): boolean {
const [state, setState] = useState<AuthState>({ workspaceId: undefined, authed: false });
useEffect(() => {
if (!workspaceId) return;
let cancelled = false;
async function refresh() {
try {
const cfg = await getLinearConfig(workspaceId!);
if (cancelled) return;
setState({ workspaceId, authed: !!cfg?.hasSecret && !!cfg.lastOk });
} catch {
if (!cancelled) setState({ workspaceId, authed: false });
}
}
void refresh();
const id = setInterval(() => void refresh(), LINEAR_STATUS_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [workspaceId]);
return state.workspaceId === workspaceId && state.authed;
}
// Combined check for showing Linear UI: the workspace toggle is on AND the
// backend reports a configured, healthy connection.
export function useLinearAvailable(workspaceId: string | null | undefined): boolean {
const ws = workspaceId ?? undefined;
const { enabled, loaded } = useLinearEnabled(ws);
const authed = useLinearAuthed(enabled && loaded ? ws : undefined);
return enabled && authed;
}
@@ -1,73 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// Workspace-scoped: a workspace can have Linear configured but the user may
// want to silence the integration UI without removing credentials.
const storageKey = (workspaceId: string) => `kandev:linear:enabled:${workspaceId}:v1`;
const SYNC_EVENT = "kandev:linear: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;
}
}
// State tagged with the workspace it was read for. Comparing the tag against
// the currently-requested workspaceId at read time gives us a stale-free
// `loaded` signal without calling setState synchronously inside the effect
// body (which the react-hooks lint rule forbids).
type EnabledState = { workspaceId: string | undefined; enabled: boolean };
export function useLinearEnabled(workspaceId: string | undefined) {
const [state, setState] = useState<EnabledState>({ workspaceId: undefined, enabled: true });
useEffect(() => {
let cancelled = false;
async function init() {
if (cancelled) return;
const next = workspaceId ? readEnabled(workspaceId) : true;
setState({ workspaceId, enabled: next });
}
void init();
if (!workspaceId) return;
const onChange = () => {
const next = readEnabled(workspaceId);
setState({ workspaceId, enabled: next });
};
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.
}
setState({ workspaceId, enabled: next });
},
[workspaceId],
);
// `loaded` flips true once the state's workspace tag matches the caller's;
// this naturally resets when workspaceId changes without us ever writing a
// synchronous `setLoaded(false)` inside the effect.
const loaded = state.workspaceId === workspaceId;
const enabled = loaded ? state.enabled : true;
return { enabled, setEnabled, loaded };
}
+2 -2
View File
@@ -32,8 +32,8 @@ import { JiraTicketButton, extractJiraKey } from "@/components/jira/jira-ticket-
import { JiraLinkButton } from "@/components/jira/jira-link-button";
import { LinearIssueButton, extractLinearKey } from "@/components/linear/linear-issue-button";
import { LinearLinkButton } from "@/components/linear/linear-link-button";
import { useJiraAvailable } from "@/components/jira/my-jira/use-jira-availability";
import { useLinearAvailable } from "@/components/linear/use-linear-availability";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
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";
@@ -0,0 +1,80 @@
"use client";
import { useEffect, useState } from "react";
// The backend poller probes credentials roughly every 90s. Refreshing at the
// same cadence keeps the UI no more than ~one cycle stale.
export const INTEGRATION_STATUS_REFRESH_MS = 90_000;
// Shape returned by every integration's `getXConfig` response that this hook
// cares about. Each integration's full config can extend it freely.
export type IntegrationConfigStatus = {
hasSecret?: boolean;
lastOk?: boolean;
};
// Reads the backend-recorded auth health for a workspace. Returns true only
// when a config exists, has a secret, and the most recent probe succeeded.
// Pass `undefined` to short-circuit (no fetch, no interval).
//
// State is tagged with the workspace it was probed for so a workspace switch
// invalidates stale results synchronously at read time — no reset needed,
// which keeps the 90s poll tick from flickering the UI between request
// dispatch and response.
type AuthState = { workspaceId: string | undefined; authed: boolean };
export function useIntegrationAuthed(
workspaceId: string | undefined,
fetchConfig: (workspaceId: string) => Promise<IntegrationConfigStatus | null>,
refreshMs: number = INTEGRATION_STATUS_REFRESH_MS,
): boolean {
const [state, setState] = useState<AuthState>({ workspaceId: undefined, authed: false });
useEffect(() => {
if (!workspaceId) return;
let cancelled = false;
// Monotonic request id: if a slow earlier probe finishes after a newer
// one we ignore it, otherwise an old "auth ok" could clobber a fresh
// "auth failed" (or vice versa) and the UI would flap until the next
// tick.
let requestId = 0;
async function refresh() {
const current = ++requestId;
try {
const cfg = await fetchConfig(workspaceId!);
if (cancelled || current !== requestId) return;
setState({ workspaceId, authed: !!cfg?.hasSecret && !!cfg.lastOk });
} catch {
if (cancelled || current !== requestId) return;
setState({ workspaceId, authed: false });
}
}
void refresh();
const id = setInterval(() => void refresh(), refreshMs);
return () => {
cancelled = true;
clearInterval(id);
};
}, [workspaceId, fetchConfig, refreshMs]);
return state.workspaceId === workspaceId && state.authed;
}
export type IntegrationAvailabilityOptions = {
// Workspace-scoped enabled toggle that has settled. `loaded` gates the
// probe so we don't waste a fetch on the first render when the toggle is
// off.
useEnabled: (workspaceId: string | undefined) => { enabled: boolean; loaded: boolean };
fetchConfig: (workspaceId: string) => Promise<IntegrationConfigStatus | null>;
refreshMs?: number;
};
// Combined check for showing an integration's UI: the workspace toggle is on
// AND the backend reports a configured, healthy connection.
export function useIntegrationAvailable(
workspaceId: string | null | undefined,
{ useEnabled, fetchConfig, refreshMs }: IntegrationAvailabilityOptions,
): boolean {
const ws = workspaceId ?? undefined;
const { enabled, loaded } = useEnabled(ws);
const authed = useIntegrationAuthed(enabled && loaded ? ws : undefined, fetchConfig, refreshMs);
return enabled && authed;
}
@@ -0,0 +1,88 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// useIntegrationEnabled is the workspace-scoped on/off toggle every
// third-party integration UI (jira, linear, future) needs: a localStorage-
// backed boolean that defaults to true, syncs across tabs via the `storage`
// event, and within a tab via a custom event the integration provides.
//
// State is tagged with the workspace it was read for. Comparing the tag at
// read time gives us a stale-free `loaded` signal without ever having to call
// setLoaded(false) inside the effect (which would be a sync-set-state inside
// useEffect that the react-hooks lint rule forbids).
type EnabledState = { workspaceId: string | undefined; enabled: boolean };
export type IntegrationEnabledOptions = {
// localStorage key for this workspace, e.g. `kandev:jira:enabled:ws-1:v1`.
storageKey: (workspaceId: string) => string;
// Custom event fired on the window when the toggle changes within a tab.
// The `storage` event only fires across tabs, so each integration needs
// its own intra-tab signal — different integrations get different events
// so toggling one doesn't re-render every consumer of the others.
syncEvent: string;
};
function readEnabled(
workspaceId: string,
storageKey: IntegrationEnabledOptions["storageKey"],
): 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 useIntegrationEnabled(
workspaceId: string | undefined,
{ storageKey, syncEvent }: IntegrationEnabledOptions,
) {
const [state, setState] = useState<EnabledState>({ workspaceId: undefined, enabled: true });
useEffect(() => {
let cancelled = false;
function init() {
if (cancelled) return;
const next = workspaceId ? readEnabled(workspaceId, storageKey) : true;
setState({ workspaceId, enabled: next });
}
init();
if (!workspaceId) return;
const onChange = () => {
const next = readEnabled(workspaceId, storageKey);
setState({ workspaceId, enabled: next });
};
window.addEventListener("storage", onChange);
window.addEventListener(syncEvent, onChange);
return () => {
cancelled = true;
window.removeEventListener("storage", onChange);
window.removeEventListener(syncEvent, onChange);
};
}, [workspaceId, storageKey, syncEvent]);
const setEnabled = useCallback(
(next: boolean) => {
if (!workspaceId || typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey(workspaceId), String(next));
window.dispatchEvent(new Event(syncEvent));
} catch {
// Quota or private mode: state still updates in-memory.
}
setState({ workspaceId, enabled: next });
},
[workspaceId, storageKey, syncEvent],
);
// `loaded` flips true once the state's workspace tag matches the caller's;
// this naturally resets when workspaceId changes without us ever writing a
// synchronous `setLoaded(false)` inside the effect.
const loaded = state.workspaceId === workspaceId;
const enabled = loaded ? state.enabled : true;
return { enabled, setEnabled, loaded };
}
@@ -0,0 +1,19 @@
"use client";
import { getJiraConfig } from "@/lib/api/domains/jira-api";
import {
useIntegrationAuthed,
useIntegrationAvailable,
} from "../integrations/use-integration-availability";
import { useJiraEnabled } from "./use-jira-enabled";
export function useJiraAuthed(workspaceId: string | undefined): boolean {
return useIntegrationAuthed(workspaceId, getJiraConfig);
}
export function useJiraAvailable(workspaceId: string | null | undefined): boolean {
return useIntegrationAvailable(workspaceId, {
useEnabled: useJiraEnabled,
fetchConfig: getJiraConfig,
});
}
@@ -0,0 +1,13 @@
"use client";
import { useIntegrationEnabled } from "../integrations/use-integration-enabled";
// 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`;
const SYNC_EVENT = "kandev:jira:enabled-changed";
export function useJiraEnabled(workspaceId: string | undefined) {
return useIntegrationEnabled(workspaceId, { storageKey, syncEvent: SYNC_EVENT });
}
@@ -0,0 +1,19 @@
"use client";
import { getLinearConfig } from "@/lib/api/domains/linear-api";
import {
useIntegrationAuthed,
useIntegrationAvailable,
} from "../integrations/use-integration-availability";
import { useLinearEnabled } from "./use-linear-enabled";
export function useLinearAuthed(workspaceId: string | undefined): boolean {
return useIntegrationAuthed(workspaceId, getLinearConfig);
}
export function useLinearAvailable(workspaceId: string | null | undefined): boolean {
return useIntegrationAvailable(workspaceId, {
useEnabled: useLinearEnabled,
fetchConfig: getLinearConfig,
});
}
@@ -0,0 +1,13 @@
"use client";
import { useIntegrationEnabled } from "../integrations/use-integration-enabled";
// Workspace-scoped: a workspace can have Linear configured but the user may
// want to silence the integration UI without removing credentials.
const storageKey = (workspaceId: string) => `kandev:linear:enabled:${workspaceId}:v1`;
const SYNC_EVENT = "kandev:linear:enabled-changed";
export function useLinearEnabled(workspaceId: string | undefined) {
return useIntegrationEnabled(workspaceId, { storageKey, syncEvent: SYNC_EVENT });
}