804 lines
28 KiB
Go
804 lines
28 KiB
Go
package jira
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/watchreset"
|
|
)
|
|
|
|
// SecretStore is the subset of the secrets store the service needs. Kept small
|
|
// so tests can fake it easily.
|
|
type SecretStore interface {
|
|
Reveal(ctx context.Context, id string) (string, error)
|
|
Set(ctx context.Context, id, name, value string) error
|
|
Delete(ctx context.Context, id string) error
|
|
Exists(ctx context.Context, id string) (bool, error)
|
|
}
|
|
|
|
// Service orchestrates Jira config storage, the cached client, and the
|
|
// fetch/transition operations used by the WebSocket + HTTP handlers.
|
|
type Service struct {
|
|
store *Store
|
|
secrets SecretStore
|
|
log *logger.Logger
|
|
mu sync.Mutex
|
|
clientFn ClientFactory
|
|
client Client // singleton, cleared on config change.
|
|
probeHook func()
|
|
eventBus bus.EventBus
|
|
// taskDeleter is the cascade-delete entry point used by the watch
|
|
// reset flow. Wired post-construction via SetTaskDeleter to avoid
|
|
// the import cycle that would result from passing the concrete
|
|
// *task.HandoffService into NewService.
|
|
taskDeleter watchreset.TaskDeleter
|
|
// mockClient is non-nil only when Provide built the service with a MockClient
|
|
// (KANDEV_MOCK_JIRA=true). Exposed via MockClient() so the e2e control routes
|
|
// can drive the same instance the clientFn returns.
|
|
mockClient *MockClient
|
|
}
|
|
|
|
// SetTaskDeleter wires the cascade-delete dependency used by ResetIssueWatch.
|
|
// Optional — when unset, reset returns an error so the missing wiring is
|
|
// surfaced instead of silently no-op'ing.
|
|
func (s *Service) SetTaskDeleter(td watchreset.TaskDeleter) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.taskDeleter = td
|
|
}
|
|
|
|
// MockClient returns the shared mock client when the service was built in mock
|
|
// mode, or nil for production builds.
|
|
func (s *Service) MockClient() *MockClient {
|
|
return s.mockClient
|
|
}
|
|
|
|
// ClientFactory builds a Client for the given config + secret. Overridable so
|
|
// tests can inject fakes without touching HTTP.
|
|
type ClientFactory func(cfg *JiraConfig, secret string) Client
|
|
|
|
// DefaultClientFactory returns a real CloudClient.
|
|
func DefaultClientFactory(cfg *JiraConfig, secret string) Client {
|
|
return NewCloudClient(cfg, secret)
|
|
}
|
|
|
|
// NewService wires the service with a store, secret backend, and client
|
|
// factory. Pass nil for clientFn to use DefaultClientFactory.
|
|
func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *logger.Logger) *Service {
|
|
if clientFn == nil {
|
|
clientFn = DefaultClientFactory
|
|
}
|
|
return &Service{
|
|
store: store,
|
|
secrets: secrets,
|
|
log: log,
|
|
clientFn: clientFn,
|
|
}
|
|
}
|
|
|
|
// GetConfig returns the singleton config enriched with a HasSecret flag so the
|
|
// UI can distinguish "configured but empty" from "needs credentials". For
|
|
// session_cookie auth, it also tries to decode the JWT in the stored cookie
|
|
// and surface its expiry so the UI can warn the user before the session dies.
|
|
func (s *Service) GetConfig(ctx context.Context) (*JiraConfig, error) {
|
|
cfg, err := s.store.GetConfig(ctx)
|
|
if err != nil || cfg == nil {
|
|
return cfg, err
|
|
}
|
|
if s.secrets == nil {
|
|
return cfg, nil
|
|
}
|
|
exists, existsErr := s.secrets.Exists(ctx, SecretKey)
|
|
if existsErr != nil {
|
|
s.log.Warn("jira: secret exists check failed", zap.Error(existsErr))
|
|
}
|
|
cfg.HasSecret = exists
|
|
if exists && cfg.AuthMethod == AuthMethodSessionCookie {
|
|
secret, revealErr := s.secrets.Reveal(ctx, SecretKey)
|
|
if revealErr != nil {
|
|
s.log.Warn("jira: secret reveal failed", zap.Error(revealErr))
|
|
} else {
|
|
cfg.SecretExpiresAt = parseSessionCookieExpiry(secret)
|
|
}
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// ErrInvalidConfig is returned by SetConfig when the request fails validation
|
|
// (missing site URL, bad auth method, etc.). Callers map it to HTTP 400.
|
|
var ErrInvalidConfig = errors.New("jira: invalid configuration")
|
|
|
|
// SetConfig is upsert: it writes the singleton row and, when a new secret is
|
|
// provided, stores it in the encrypted secret store. An empty Secret means
|
|
// "keep the existing token" — this lets the UI edit auxiliary fields without
|
|
// forcing the user to paste the token again.
|
|
func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*JiraConfig, error) {
|
|
if err := validateConfigRequest(req); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error())
|
|
}
|
|
cfg := &JiraConfig{
|
|
SiteURL: normalizeSiteURL(req.SiteURL),
|
|
Email: req.Email,
|
|
AuthMethod: req.AuthMethod,
|
|
InstanceType: req.InstanceType,
|
|
DefaultProjectKey: req.DefaultProjectKey,
|
|
}
|
|
if err := s.store.UpsertConfig(ctx, cfg); err != nil {
|
|
return nil, fmt.Errorf("upsert jira config: %w", err)
|
|
}
|
|
if req.Secret != "" && s.secrets != nil {
|
|
if err := s.secrets.Set(ctx, SecretKey, "Jira token", req.Secret); err != nil {
|
|
return nil, fmt.Errorf("store jira secret: %w", err)
|
|
}
|
|
}
|
|
s.invalidateClient()
|
|
// Probe asynchronously so a slow Atlassian doesn't stall the save response.
|
|
// RecordAuthHealth manages its own probe timeout, so a fresh
|
|
// context.Background() is enough — the request ctx may be cancelled when
|
|
// this returns, but the probe and the DB write must still complete.
|
|
go func() {
|
|
s.RecordAuthHealth(context.Background())
|
|
}()
|
|
return s.GetConfig(ctx)
|
|
}
|
|
|
|
// DeleteConfig removes both the config row and the stored secret. A failure to
|
|
// delete the secret is logged (not returned) so the config row deletion still
|
|
// surfaces success — the orphaned secret is rare and the user can retry by
|
|
// re-saving + deleting again.
|
|
func (s *Service) DeleteConfig(ctx context.Context) error {
|
|
if err := s.store.DeleteConfig(ctx); err != nil {
|
|
return err
|
|
}
|
|
if s.secrets != nil {
|
|
if err := s.secrets.Delete(ctx, SecretKey); err != nil {
|
|
s.log.Warn("jira: secret delete failed", zap.Error(err))
|
|
}
|
|
}
|
|
s.invalidateClient()
|
|
return nil
|
|
}
|
|
|
|
// TestConnection validates credentials either from a fresh SetConfigRequest
|
|
// (before persisting) or from the stored config (after saving). Returns a
|
|
// structured result rather than an error so the UI can render the failure
|
|
// inline.
|
|
func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) {
|
|
cfg, secret, err := s.resolveCredentials(ctx, req)
|
|
if err != nil {
|
|
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
|
|
}
|
|
client := s.clientFn(cfg, secret)
|
|
return client.TestAuth(ctx)
|
|
}
|
|
|
|
// ProbeAuth validates the stored credentials by hitting the cheapest
|
|
// authenticated endpoint. Used by the background auth-health poller to detect
|
|
// expired session cookies / step-up auth before the user clicks through to a
|
|
// tab that would 303. Errors are returned as a result so the poller can
|
|
// persist them as a failure rather than dropping them.
|
|
func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) {
|
|
client, err := s.clientFor(ctx)
|
|
if err != nil {
|
|
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
|
|
}
|
|
return client.TestAuth(ctx)
|
|
}
|
|
|
|
// Store exposes the underlying store so background workers (e.g. the auth
|
|
// health poller) can persist state without re-implementing the secrets+config
|
|
// resolution that the service already encapsulates.
|
|
func (s *Service) Store() *Store {
|
|
return s.store
|
|
}
|
|
|
|
// authProbeTimeout caps a single auth-health probe so a slow Atlassian can't
|
|
// stall the caller. The /myself endpoint typically responds in <500ms.
|
|
const authProbeTimeout = 15 * time.Second
|
|
|
|
// authHealthWriteTimeout bounds the DB write that persists the probe outcome.
|
|
// Always derived from a fresh context.Background so a probe that exhausted its
|
|
// own deadline can still record the failure.
|
|
const authHealthWriteTimeout = 5 * time.Second
|
|
|
|
// SetProbeHook installs a callback fired at the end of each RecordAuthHealth
|
|
// call (after the DB write). Production code never sets this; tests use it to
|
|
// synchronize on probe completion without sleep-polling.
|
|
func (s *Service) SetProbeHook(fn func()) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.probeHook = fn
|
|
}
|
|
|
|
// RecordAuthHealth probes the stored credentials and writes the outcome onto
|
|
// the JiraConfig row. Used both at config-save time (so the UI reflects the
|
|
// new credentials immediately) and by the background poller. Errors during
|
|
// the persist step are logged but not returned: this is a best-effort health
|
|
// signal, never the source of truth for callers.
|
|
func (s *Service) RecordAuthHealth(ctx context.Context) {
|
|
probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout)
|
|
defer cancel()
|
|
res, err := s.ProbeAuth(probeCtx)
|
|
ok := err == nil && res != nil && res.OK
|
|
errMsg := ""
|
|
switch {
|
|
case err != nil:
|
|
errMsg = err.Error()
|
|
case res != nil && !res.OK:
|
|
errMsg = res.Error
|
|
}
|
|
// Detach the DB write from ctx: a probe that exhausted its 15s deadline
|
|
// would otherwise pass an already-expired context to UpdateAuthHealth and
|
|
// drop the failure record on the floor (so the UI never flips to "auth
|
|
// failed" until the next poll).
|
|
writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout)
|
|
defer writeCancel()
|
|
if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, time.Now().UTC()); updateErr != nil {
|
|
s.log.Warn("jira: update auth health failed", zap.Error(updateErr))
|
|
}
|
|
s.mu.Lock()
|
|
hook := s.probeHook
|
|
s.mu.Unlock()
|
|
if hook != nil {
|
|
hook()
|
|
}
|
|
}
|
|
|
|
// GetTicket loads a Jira ticket by key using the stored credentials.
|
|
func (s *Service) GetTicket(ctx context.Context, ticketKey string) (*JiraTicket, error) {
|
|
client, err := s.clientFor(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.GetTicket(ctx, ticketKey)
|
|
}
|
|
|
|
// DoTransition applies a transition to a ticket.
|
|
func (s *Service) DoTransition(ctx context.Context, ticketKey, transitionID string) error {
|
|
client, err := s.clientFor(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return client.DoTransition(ctx, ticketKey, transitionID)
|
|
}
|
|
|
|
// ListProjects is used by the settings UI to populate the project selector.
|
|
func (s *Service) ListProjects(ctx context.Context) ([]JiraProject, error) {
|
|
client, err := s.clientFor(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.ListProjects(ctx)
|
|
}
|
|
|
|
// SearchTickets runs a JQL search, returning a page of tickets. pageToken is
|
|
// the cursor returned in the previous page's NextPageToken; pass "" for the
|
|
// first page.
|
|
func (s *Service) SearchTickets(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) {
|
|
client, err := s.clientFor(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.SearchTickets(ctx, jql, pageToken, maxResults)
|
|
}
|
|
|
|
// clientFor returns the cached client, creating it if needed. The cache is
|
|
// invalidated whenever the config changes so stale credentials never linger.
|
|
func (s *Service) clientFor(ctx context.Context) (Client, error) {
|
|
s.mu.Lock()
|
|
if s.client != nil {
|
|
c := s.client
|
|
s.mu.Unlock()
|
|
return c, nil
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
cfg, err := s.store.GetConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg == nil {
|
|
return nil, ErrNotConfigured
|
|
}
|
|
secret := ""
|
|
if s.secrets != nil {
|
|
secret, err = s.secrets.Reveal(ctx, SecretKey)
|
|
if err != nil {
|
|
// Don't conflate a transient secret-store failure with "user never
|
|
// configured Jira". The UI gates on ErrNotConfigured to render a
|
|
// configure-CTA; surfacing the real error gives the user a path to
|
|
// retry rather than leaving them stuck.
|
|
return nil, fmt.Errorf("read jira secret: %w", err)
|
|
}
|
|
if secret == "" {
|
|
return nil, ErrNotConfigured
|
|
}
|
|
}
|
|
client := s.clientFn(cfg, secret)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
// Re-check: another caller may have populated the cache while we were
|
|
// fetching the config and secret. Returning the existing client keeps the
|
|
// cache identity stable so callers comparing pointers don't see flapping.
|
|
if s.client != nil {
|
|
return s.client, nil
|
|
}
|
|
s.client = client
|
|
return client, nil
|
|
}
|
|
|
|
// invalidateClient drops the cached client so the next request rebuilds it
|
|
// with fresh credentials.
|
|
func (s *Service) invalidateClient() {
|
|
s.mu.Lock()
|
|
s.client = nil
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// resolveCredentials picks the credentials to test: if the request carries a
|
|
// secret, use it inline (pre-save); otherwise fall back to the stored secret
|
|
// (post-save re-test).
|
|
func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*JiraConfig, string, error) {
|
|
cfg := &JiraConfig{
|
|
SiteURL: normalizeSiteURL(req.SiteURL),
|
|
Email: req.Email,
|
|
AuthMethod: req.AuthMethod,
|
|
InstanceType: req.InstanceType,
|
|
}
|
|
var secret string
|
|
if req.Secret != "" {
|
|
secret = req.Secret
|
|
} else {
|
|
if s.secrets == nil {
|
|
return nil, "", errors.New("no secret store configured")
|
|
}
|
|
var err error
|
|
secret, err = s.secrets.Reveal(ctx, SecretKey)
|
|
if err != nil {
|
|
// Don't conflate a transient secret-store failure with "no token
|
|
// stored". Surfacing the real error gives the user a path to retry
|
|
// instead of telling them to paste a token they already have.
|
|
s.log.Warn("jira: secret reveal failed", zap.Error(err))
|
|
return nil, "", fmt.Errorf("read jira secret: %w", err)
|
|
}
|
|
if secret == "" {
|
|
return nil, "", errors.New("no token stored — paste one to test")
|
|
}
|
|
}
|
|
// Merge with persisted config only when the caller didn't pass enough on
|
|
// its own. A fully-specified pre-save TestConnection request needs no DB
|
|
// read — keeping the call gated avoids a transient DB error blocking auth
|
|
// testing for a request that doesn't depend on persisted state. When a
|
|
// read does happen, surface its error so a real DB failure isn't masked
|
|
// as an opaque "missing field" validation message later on.
|
|
needsStoredConfig := cfg.SiteURL == "" ||
|
|
cfg.AuthMethod == "" ||
|
|
cfg.InstanceType == "" ||
|
|
(cfg.AuthMethod == AuthMethodAPIToken && cfg.Email == "")
|
|
if needsStoredConfig {
|
|
if err := s.fillConfigFromStored(ctx, cfg); err != nil {
|
|
return nil, "", err
|
|
}
|
|
}
|
|
if cfg.InstanceType == "" {
|
|
cfg.InstanceType = InstanceTypeCloud
|
|
}
|
|
// Run the same auth/instance check the save path runs, so TestConnection
|
|
// reports invalid combos (e.g. pat+cloud, api_token+server) with a clear
|
|
// validation error instead of letting Jira return an opaque 401.
|
|
if err := validateAuthInstance(cfg.AuthMethod, cfg.InstanceType, cfg.Email); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return cfg, secret, nil
|
|
}
|
|
|
|
// fillConfigFromStored reads the persisted singleton config and copies any
|
|
// fields the caller left blank onto cfg. A real read failure is returned so
|
|
// callers don't mask DB errors as opaque "missing field" validation errors;
|
|
// a missing row (stored == nil) is treated as "nothing to fill" rather than
|
|
// an error so first-time TestConnection requests still go through.
|
|
func (s *Service) fillConfigFromStored(ctx context.Context, cfg *JiraConfig) error {
|
|
stored, err := s.store.GetConfig(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("read jira config: %w", err)
|
|
}
|
|
if stored == nil {
|
|
return nil
|
|
}
|
|
if cfg.SiteURL == "" {
|
|
cfg.SiteURL = stored.SiteURL
|
|
}
|
|
if cfg.Email == "" {
|
|
cfg.Email = stored.Email
|
|
}
|
|
if cfg.AuthMethod == "" {
|
|
cfg.AuthMethod = stored.AuthMethod
|
|
}
|
|
if cfg.InstanceType == "" {
|
|
cfg.InstanceType = stored.InstanceType
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateConfigRequest(req *SetConfigRequest) error {
|
|
if req.SiteURL == "" {
|
|
return errors.New("siteUrl required")
|
|
}
|
|
// Require an explicit instanceType — defaulting to cloud here would let a
|
|
// stale client (one that pre-dates Server/DC support) silently downgrade
|
|
// a saved Server config back to Cloud on the next partial save.
|
|
switch req.InstanceType {
|
|
case InstanceTypeCloud, InstanceTypeServer:
|
|
case "":
|
|
return errors.New("instanceType required (cloud or server)")
|
|
default:
|
|
return fmt.Errorf("unknown instance type: %q", req.InstanceType)
|
|
}
|
|
return validateAuthInstance(req.AuthMethod, req.InstanceType, req.Email)
|
|
}
|
|
|
|
// validateAuthInstance enforces the supported (auth method, instance type)
|
|
// combinations. Shared by config-save and the test-connection flow so a
|
|
// pre-save test surfaces the same diagnostic the eventual save would, instead
|
|
// of letting Jira return an opaque 401 for an unsupported combo.
|
|
func validateAuthInstance(authMethod, instanceType, email string) error {
|
|
switch authMethod {
|
|
case AuthMethodAPIToken:
|
|
// API tokens are Cloud-only — Atlassian Server/DC rejects Basic auth
|
|
// with `id.atlassian.com` tokens and redirects to a login page, which
|
|
// makes the failure mode opaque. Catch it at config time.
|
|
if instanceType != InstanceTypeCloud {
|
|
return errors.New("api_token auth is supported only on Atlassian Cloud — use pat for Server/DC")
|
|
}
|
|
if email == "" {
|
|
return errors.New("email required for api_token auth")
|
|
}
|
|
case AuthMethodPAT:
|
|
// Personal Access Tokens are a Server/DC concept; Cloud expects
|
|
// id.atlassian.com tokens via Basic, not Bearer.
|
|
if instanceType != InstanceTypeServer {
|
|
return errors.New("pat auth is supported only on Jira Server/Data Center — use api_token for Cloud")
|
|
}
|
|
case AuthMethodSessionCookie:
|
|
// The client wraps the session secret under cloud.session.token and
|
|
// tenant.session.token — both Atlassian-Cloud-specific cookie names.
|
|
// Server/DC uses JSESSIONID, so the current wrapping is a no-op there;
|
|
// reject the combo until we add a Server-aware session-cookie path.
|
|
if instanceType != InstanceTypeCloud {
|
|
return errors.New("session_cookie auth is supported only on Atlassian Cloud — use pat for Server/DC")
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown auth method: %q", authMethod)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeSiteURL trims trailing slashes and prepends https:// when the user
|
|
// typed only a hostname (e.g. "acme.atlassian.net"). Without a scheme the Go
|
|
// HTTP client fails with "unsupported protocol scheme".
|
|
func normalizeSiteURL(raw string) string {
|
|
s := strings.TrimSpace(raw)
|
|
s = strings.TrimRight(s, "/")
|
|
if s == "" {
|
|
return s
|
|
}
|
|
if !strings.Contains(s, "://") {
|
|
s = "https://" + s
|
|
}
|
|
return s
|
|
}
|
|
|
|
// SetEventBus wires the bus used to publish NewJiraIssueEvent. Optional: if
|
|
// unset the poller still runs but observed tickets do not become Kandev tasks
|
|
// — useful in tests that exercise the polling loop in isolation.
|
|
func (s *Service) SetEventBus(eb bus.EventBus) {
|
|
s.mu.Lock()
|
|
s.eventBus = eb
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// --- Issue watch CRUD (thin pass-throughs to the store) ---
|
|
|
|
// ErrIssueWatchNotFound is returned when GetIssueWatch's caller looks up an ID
|
|
// that doesn't exist. Callers map this to HTTP 404.
|
|
var ErrIssueWatchNotFound = errors.New("jira: issue watch not found")
|
|
|
|
// CreateIssueWatch validates the request and persists a new watch row.
|
|
func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) {
|
|
if err := validateIssueWatchCreate(req); err != nil {
|
|
return nil, err
|
|
}
|
|
w := &IssueWatch{
|
|
WorkspaceID: req.WorkspaceID,
|
|
WorkflowID: req.WorkflowID,
|
|
WorkflowStepID: req.WorkflowStepID,
|
|
JQL: strings.TrimSpace(req.JQL),
|
|
AgentProfileID: req.AgentProfileID,
|
|
ExecutorProfileID: req.ExecutorProfileID,
|
|
Prompt: req.Prompt,
|
|
PollIntervalSeconds: req.PollIntervalSeconds,
|
|
MaxInflightTasks: req.MaxInflightTasks,
|
|
Enabled: true,
|
|
}
|
|
if req.Enabled != nil {
|
|
w.Enabled = *req.Enabled
|
|
}
|
|
if err := s.store.CreateIssueWatch(ctx, w); err != nil {
|
|
return nil, err
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
// ListIssueWatches returns the watches configured for a workspace.
|
|
func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) {
|
|
return s.store.ListIssueWatches(ctx, workspaceID)
|
|
}
|
|
|
|
// ListAllIssueWatches returns every watch across all workspaces.
|
|
func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) {
|
|
return s.store.ListAllIssueWatches(ctx)
|
|
}
|
|
|
|
// GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound.
|
|
func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) {
|
|
w, err := s.store.GetIssueWatch(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if w == nil {
|
|
return nil, ErrIssueWatchNotFound
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
// UpdateIssueWatch applies a partial update by patching only the fields the
|
|
// caller explicitly set, then persists the result.
|
|
func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) (*IssueWatch, error) {
|
|
w, err := s.GetIssueWatch(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyIssueWatchPatch(w, req)
|
|
// Empty-string PATCH writes (`{"workflowId": ""}` etc.) bypass the nil
|
|
// guard in applyIssueWatchPatch — Go's JSON decoder returns a non-nil
|
|
// pointer to "". Guard the post-patch state so a PATCH can't strip a row
|
|
// of the fields the orchestrator needs to create tasks.
|
|
if w.JQL == "" {
|
|
return nil, fmt.Errorf("%w: jql cannot be empty", ErrInvalidConfig)
|
|
}
|
|
if w.WorkflowID == "" || w.WorkflowStepID == "" {
|
|
return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig)
|
|
}
|
|
if err := validatePollInterval(w.PollIntervalSeconds); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateMaxInflightTasks(w.MaxInflightTasks); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.store.UpdateIssueWatch(ctx, w); err != nil {
|
|
return nil, err
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
// DeleteIssueWatch removes the watch and its dedup rows. Idempotent: deleting
|
|
// a missing ID is a silent success.
|
|
func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error {
|
|
return s.store.DeleteIssueWatch(ctx, id)
|
|
}
|
|
|
|
// jiraIssueWatchResetter is the watchreset.Resetter adapter for a single
|
|
// Jira issue watch. Closes over the store and watch ID so the shared
|
|
// watchreset.Run helper stays integration-agnostic.
|
|
type jiraIssueWatchResetter struct {
|
|
store *Store
|
|
watchID string
|
|
}
|
|
|
|
func (r *jiraIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
|
|
return r.store.ListIssueWatchTaskIDs(ctx, r.watchID)
|
|
}
|
|
|
|
func (r *jiraIssueWatchResetter) Clear(ctx context.Context) error {
|
|
return r.store.ResetIssueWatchState(ctx, r.watchID)
|
|
}
|
|
|
|
// PreviewResetIssueWatch returns how many tasks ResetIssueWatch would
|
|
// cascade-delete. Used by the frontend to populate the confirmation dialog.
|
|
func (s *Service) PreviewResetIssueWatch(ctx context.Context, watchID string) (int, error) {
|
|
return watchreset.Preview(ctx, &jiraIssueWatchResetter{store: s.store, watchID: watchID})
|
|
}
|
|
|
|
// ResetIssueWatch is destructive: cascade-deletes every task previously
|
|
// created by the watch (including archived), wipes the per-watch dedup
|
|
// rows, and nulls last_polled_at so the next poll re-imports every
|
|
// currently-matching ticket. Returns the count of tasks deleted.
|
|
func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, error) {
|
|
s.mu.Lock()
|
|
td := s.taskDeleter
|
|
s.mu.Unlock()
|
|
if td == nil {
|
|
return 0, errors.New("jira: task deleter not wired; reset unavailable")
|
|
}
|
|
res, err := watchreset.Run(ctx,
|
|
&jiraIssueWatchResetter{store: s.store, watchID: watchID},
|
|
td, s.log)
|
|
return res.TasksDeleted, err
|
|
}
|
|
|
|
// CheckIssueWatch runs the watch's JQL once and returns the tickets that
|
|
// haven't been turned into tasks yet. last_polled_at is stamped regardless of
|
|
// whether the search succeeded — a failing search still counts as "we tried"
|
|
// so the UI can show liveness.
|
|
func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*JiraTicket, error) {
|
|
defer s.stampLastPolled(w.ID)
|
|
client, err := s.clientFor(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Single page is enough per tick: the next poll picks up anything that
|
|
// overflowed maxResults. Pagination would let one chatty workspace starve
|
|
// the others without any real freshness benefit.
|
|
res, err := client.SearchTickets(ctx, w.JQL, "", issueWatchSearchPageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Bulk-fetch the dedup set once per call instead of one query per ticket —
|
|
// a broad JQL can return up to issueWatchSearchPageSize tickets, so a
|
|
// per-ticket round-trip would multiply the per-tick cost.
|
|
seen, err := s.store.ListSeenIssueKeys(ctx, w.ID)
|
|
if err != nil {
|
|
s.log.Warn("jira: dedup set fetch failed",
|
|
zap.String("watch_id", w.ID), zap.Error(err))
|
|
seen = nil // fall through: a missing dedup set is safer than dropping all tickets
|
|
}
|
|
out := make([]*JiraTicket, 0, len(res.Tickets))
|
|
for i := range res.Tickets {
|
|
t := res.Tickets[i]
|
|
if _, ok := seen[t.Key]; ok {
|
|
continue
|
|
}
|
|
out = append(out, &t)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// stampLastPolled writes the current timestamp onto the watch row using a
|
|
// fresh background context with a short write deadline. The caller's ctx may
|
|
// already be cancelled (typical at poller shutdown), which would cause the
|
|
// DB write to fail silently and the watch would look "never polled" on the
|
|
// next backend restart. Mirrors the pattern in RecordAuthHealth.
|
|
func (s *Service) stampLastPolled(watchID string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), authHealthWriteTimeout)
|
|
defer cancel()
|
|
if err := s.store.UpdateIssueWatchLastPolled(ctx, watchID, time.Now().UTC()); err != nil {
|
|
s.log.Warn("jira: update last_polled_at failed",
|
|
zap.String("watch_id", watchID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// publishNewJiraIssueEvent emits the orchestrator-facing event for one freshly
|
|
// observed ticket. No-op when the event bus is not wired (tests, early boot).
|
|
func (s *Service) publishNewJiraIssueEvent(ctx context.Context, w *IssueWatch, ticket *JiraTicket) {
|
|
s.mu.Lock()
|
|
eb := s.eventBus
|
|
s.mu.Unlock()
|
|
if eb == nil {
|
|
return
|
|
}
|
|
evt := bus.NewEvent(events.JiraNewIssue, "jira", &NewJiraIssueEvent{
|
|
IssueWatchID: w.ID,
|
|
WorkspaceID: w.WorkspaceID,
|
|
WorkflowID: w.WorkflowID,
|
|
WorkflowStepID: w.WorkflowStepID,
|
|
AgentProfileID: w.AgentProfileID,
|
|
ExecutorProfileID: w.ExecutorProfileID,
|
|
Prompt: w.Prompt,
|
|
MaxInflightTasks: w.MaxInflightTasks,
|
|
Issue: ticket,
|
|
})
|
|
if err := eb.Publish(ctx, events.JiraNewIssue, evt); err != nil {
|
|
s.log.Debug("jira: publish new issue event failed",
|
|
zap.String("watch_id", w.ID), zap.String("issue_key", ticket.Key), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// issueWatchSearchPageSize caps how many tickets a single CheckIssueWatch call
|
|
// pulls from JIRA. 50 is well under the API's 100-result limit and keeps the
|
|
// per-tick cost bounded even for very broad JQL queries.
|
|
const issueWatchSearchPageSize = 50
|
|
|
|
// MinIssueWatchPollInterval / MaxIssueWatchPollInterval bound the per-watch
|
|
// JQL re-run cadence. The lower limit protects Atlassian from rapid-fire
|
|
// polling; the upper limit keeps "stuck" rows from polling so rarely they
|
|
// look broken to the user.
|
|
const (
|
|
MinIssueWatchPollInterval = 60
|
|
MaxIssueWatchPollInterval = 3600
|
|
)
|
|
|
|
func validateIssueWatchCreate(req *CreateIssueWatchRequest) error {
|
|
if req.WorkspaceID == "" {
|
|
return fmt.Errorf("%w: workspaceId required", ErrInvalidConfig)
|
|
}
|
|
if req.WorkflowID == "" || req.WorkflowStepID == "" {
|
|
return fmt.Errorf("%w: workflowId and workflowStepId required", ErrInvalidConfig)
|
|
}
|
|
if strings.TrimSpace(req.JQL) == "" {
|
|
return fmt.Errorf("%w: jql required", ErrInvalidConfig)
|
|
}
|
|
// 0 / unset is allowed — the store coerces to DefaultIssueWatchPollInterval.
|
|
// Any positive value must fall inside the documented bounds.
|
|
if req.PollIntervalSeconds != 0 {
|
|
if err := validatePollInterval(req.PollIntervalSeconds); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := validateMaxInflightTasks(req.MaxInflightTasks); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateMaxInflightTasks rejects non-positive caps. A nil pointer is
|
|
// "uncapped" and explicitly allowed.
|
|
func validateMaxInflightTasks(v *int) error {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if *v <= 0 {
|
|
return fmt.Errorf("%w: maxInflightTasks must be a positive integer", ErrInvalidConfig)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePollInterval(seconds int) error {
|
|
if seconds < MinIssueWatchPollInterval || seconds > MaxIssueWatchPollInterval {
|
|
return fmt.Errorf("%w: pollIntervalSeconds must be between %d and %d",
|
|
ErrInvalidConfig, MinIssueWatchPollInterval, MaxIssueWatchPollInterval)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyIssueWatchPatch(w *IssueWatch, req *UpdateIssueWatchRequest) {
|
|
if req.WorkflowID != nil {
|
|
w.WorkflowID = *req.WorkflowID
|
|
}
|
|
if req.WorkflowStepID != nil {
|
|
w.WorkflowStepID = *req.WorkflowStepID
|
|
}
|
|
if req.JQL != nil {
|
|
w.JQL = strings.TrimSpace(*req.JQL)
|
|
}
|
|
if req.AgentProfileID != nil {
|
|
w.AgentProfileID = *req.AgentProfileID
|
|
}
|
|
if req.ExecutorProfileID != nil {
|
|
w.ExecutorProfileID = *req.ExecutorProfileID
|
|
}
|
|
if req.Prompt != nil {
|
|
w.Prompt = *req.Prompt
|
|
}
|
|
if req.Enabled != nil {
|
|
w.Enabled = *req.Enabled
|
|
}
|
|
if req.PollIntervalSeconds != nil {
|
|
w.PollIntervalSeconds = *req.PollIntervalSeconds
|
|
}
|
|
// MaxInflightTasks is tri-state (optional.Int): only apply it when the
|
|
// PATCH actually carried the key, so a partial update that omits it
|
|
// leaves the cap intact. Present+null clears the cap; present+int sets it.
|
|
if req.MaxInflightTasks.Present {
|
|
w.MaxInflightTasks = req.MaxInflightTasks.Value
|
|
}
|
|
}
|