feat(jira): poll JQL queries to auto-create tasks (issue watchers) (#746)

* feat(jira): poll JQL queries to auto-create tasks (issue watchers)

* fix(jira): address review feedback on issue watcher

- Poller: gate per-watch by `LastPolledAt` + `PollIntervalSeconds` so the
  user-configured cadence actually applies (was silently ignored, every
  watch polled every 5 min). Tick interval reduced to 60s to match the
  smallest valid per-watch interval.
- Service: validate `pollIntervalSeconds` against the documented 60-3600s
  bounds on create/update so the UI's range check is enforced server-side.
- Service: stamp `last_polled_at` via a fresh `context.Background()` with a
  short write deadline; the poller's ctx may be cancelled at shutdown,
  which would otherwise drop the timestamp on the floor.
- Service/store: replace per-ticket `HasIssueWatchTask` round-trips with a
  single `ListSeenIssueKeys` set lookup (up to 50× fewer DB queries per
  tick on broad JQL queries).
- Hook: reset cached watch list on workspace switch — the store's `loaded`
  flag is global, so without this the table renders the previous
  workspace's watchers stale.
- Section component: pull the CRUD callbacks from a single
  `useJiraIssueWatches` call and pass them down, instead of invoking the
  hook twice on the same workspace.
- Dialog: add the three placeholders (`{{issue.type}}`, `{{issue.reporter}}`,
  `{{issue.project}}`) supported by the backend interpolator but missing
  from the help text.

* fix(jira): repair workspace-switch refetch + drop double trigger toast

The previous a9bd9fa attempt at the workspace-switch reset was incomplete:
`setJiraIssueWatches([])` flips `loaded=true`, so the fetch effect's
`if (!workspaceId || loaded || loading) return` guard still short-circuited
and the new workspace's data was never fetched. Switching workspaces left
the table empty with no recovery path.

- Add a dedicated `resetJiraIssueWatches` action that zeroes both `items`
  and `loaded`, and use it from the hook's workspace-change effect.
- Drop the inline "Checking JIRA…" toast in the watcher table — the
  parent's `wrappedTrigger` already fires a result toast on completion,
  so the click was double-toasting.

* fix(jira): reject empty workflowId/workflowStepId on issue-watch PATCH

`applyIssueWatchPatch` writes the field whenever the patch pointer is
non-nil, but Go's JSON decoder returns a non-nil pointer to "" for an
explicit `{"workflowId": ""}` body. The post-patch JQL check guarded
against the same hole for `jql`; this extends it to the workflow
fields. Without it, a stripped row would silently drop every event the
orchestrator tries to handle (the watcher looks active but produces no
tasks).

* fix(jira): require workspace ownership on issue-watch mutate/trigger

The PATCH/DELETE/trigger handlers looked up watches by the path :id with
no workspace check, while the list/create handlers correctly required
workspace_id. A user authenticated to workspace A who learned a watch
UUID from workspace B could mutate it, delete it, or trigger it
(running JQL with workspace B's stored credentials and creating tasks
there). Closes the IDOR by:

- backend: PATCH/DELETE/trigger now require ?workspace_id=... and 404
  on mismatch (404 not 403, so probing can't enumerate IDs across
  workspaces).
- frontend: api client + hook thread workspaceId through all mutation
  calls.
- store: drop the no-longer-used HasIssueWatchTask method (replaced by
  ListSeenIssueKeys after the N+1 fix); test now asserts via the
  bulk-fetched set.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-05-01 11:01:02 +01:00
committed by GitHub
parent 45ee2ff48f
commit 5693c10d8c
30 changed files with 3018 additions and 23 deletions
+5 -3
View File
@@ -333,10 +333,12 @@ func startAgentInfrastructure(
log.Info("GitHub poller started")
}
// Start JIRA auth-health poller. Probes stored credentials for every
// configured workspace on a fixed cadence so the UI can show a real
// connected/disconnected status without doing its own polling.
// Start JIRA poller. Drives two background loops sharing one service: an
// auth-health probe (so the UI can show connect status without polling
// JIRA itself) and an issue-watch loop that runs configured JQL queries
// and emits NewJiraIssueEvent for the orchestrator to turn into tasks.
if services.Jira != nil {
orchestratorSvc.SetJiraService(&jiraServiceAdapter{svc: services.Jira})
jiraPoller := jirapkg.NewPoller(services.Jira, log)
jiraPoller.Start(ctx)
addCleanup(func() error { jiraPoller.Stop(); return nil })
+19
View File
@@ -16,6 +16,7 @@ import (
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
jirapkg "github.com/kandev/kandev/internal/jira"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/repoclone"
"github.com/kandev/kandev/internal/secrets"
@@ -248,6 +249,24 @@ func (a *issueTaskCreatorAdapter) CreateIssueTask(ctx context.Context, req *orch
})
}
// jiraServiceAdapter exposes the JIRA service's issue-watch dedup methods to
// the orchestrator without leaking the rest of the package surface area.
type jiraServiceAdapter struct {
svc *jirapkg.Service
}
func (a *jiraServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, issueKey, issueURL string) (bool, error) {
return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, issueKey, issueURL)
}
func (a *jiraServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, issueKey, taskID string) error {
return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, issueKey, taskID)
}
func (a *jiraServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, issueKey string) error {
return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, issueKey)
}
// repoLocalPathUpdater adapts the task service's UpdateRepository to the executor.RepoUpdater interface.
type repoLocalPathUpdater struct {
svc *taskservice.Service
+1 -1
View File
@@ -97,7 +97,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
// Initialize JIRA service
jiraSecrets := &jiraSecretAdapter{store: repos.Secrets}
jiraSvc, _, jiraErr := jira.Provide(dbPool.Writer(), dbPool.Reader(), jiraSecrets, log)
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))
}
+5
View File
@@ -206,6 +206,11 @@ const (
GitHubWatchEvent = "github.watch.event" // Watch created/deleted
)
// Event types for Jira integration
const (
JiraNewIssue = "jira.new_issue" // New issue found matching a Jira issue watch
)
// BuildShellOutputSubject creates a shell output subject for a specific session
func BuildShellOutputSubject(sessionID string) string {
return ShellOutput + "." + sessionID
+133
View File
@@ -37,6 +37,12 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api.GET("/tickets", c.httpSearchTickets)
api.GET("/tickets/:key", c.httpGetTicket)
api.POST("/tickets/:key/transitions", c.httpDoTransition)
api.GET("/watches/issue", c.httpListIssueWatches)
api.POST("/watches/issue", c.httpCreateIssueWatch)
api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch)
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch)
}
// --- HTTP handlers ---
@@ -171,6 +177,133 @@ func (c *Controller) httpDoTransition(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"transitioned": true})
}
// --- Issue watch HTTP handlers ---
func (c *Controller) httpListIssueWatches(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"})
return
}
watches, err := c.service.ListIssueWatches(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"watches": watches})
}
func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) {
var req CreateIssueWatchRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
w, err := c.service.CreateIssueWatch(ctx.Request.Context(), &req)
if err != nil {
c.writeIssueWatchError(ctx, err)
return
}
ctx.JSON(http.StatusOK, w)
}
func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) {
id := ctx.Param("id")
if !c.assertWatchInWorkspace(ctx, id) {
return
}
var req UpdateIssueWatchRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
w, err := c.service.UpdateIssueWatch(ctx.Request.Context(), id, &req)
if err != nil {
c.writeIssueWatchError(ctx, err)
return
}
ctx.JSON(http.StatusOK, w)
}
func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) {
id := ctx.Param("id")
if !c.assertWatchInWorkspace(ctx, id) {
return
}
if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": true})
}
// httpTriggerIssueWatch runs a single immediate poll of the watch. Useful from
// the UI to verify a JQL change without waiting for the next 5-minute tick.
// Returns the count of newly-discovered tickets so the user gets feedback even
// when the matching tickets fan out asynchronously through the orchestrator.
func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) {
id := ctx.Param("id")
w, err := c.service.GetIssueWatch(ctx.Request.Context(), id)
if err != nil {
c.writeIssueWatchError(ctx, err)
return
}
if !workspaceMatches(ctx, w.WorkspaceID) {
// 404 not 403 — don't reveal whether the ID exists.
ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()})
return
}
tickets, err := c.service.CheckIssueWatch(ctx.Request.Context(), w)
if err != nil {
c.writeClientError(ctx, err)
return
}
for _, t := range tickets {
c.service.publishNewJiraIssueEvent(ctx.Request.Context(), w, t)
}
ctx.JSON(http.StatusOK, gin.H{"newIssues": len(tickets)})
}
// assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the
// caller must supply `?workspace_id=...` matching the watch's workspace. The
// list/create endpoints already require this query; mirroring it here closes
// the gap where a known watch UUID from another workspace could be mutated.
// Writes the response and returns false on mismatch — caller bails out.
func (c *Controller) assertWatchInWorkspace(ctx *gin.Context, id string) bool {
w, err := c.service.GetIssueWatch(ctx.Request.Context(), id)
if err != nil {
c.writeIssueWatchError(ctx, err)
return false
}
if !workspaceMatches(ctx, w.WorkspaceID) {
// Use 404 so a probing client can't tell whether a watch ID exists in
// another workspace.
ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()})
return false
}
return true
}
// workspaceMatches returns true when the request's `workspace_id` query param
// matches the resource's stored workspace. Empty query is rejected so the
// caller can't bypass the check by omitting the parameter.
func workspaceMatches(ctx *gin.Context, resourceWorkspace string) bool {
q := ctx.Query("workspace_id")
return q != "" && q == resourceWorkspace
}
func (c *Controller) writeIssueWatchError(ctx *gin.Context, err error) {
if errors.Is(err, ErrIssueWatchNotFound) {
ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if errors.Is(err, ErrInvalidConfig) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.writeClientError(ctx, err)
}
// errCodeJiraNotConfigured is the wire-level code surfaced to the UI when the
// workspace has no saved Jira credentials. The same string is used by both the
// HTTP and WebSocket layers so the frontend can branch on a single value.
+80
View File
@@ -118,3 +118,83 @@ type SearchResult struct {
func SecretKeyForWorkspace(workspaceID string) string {
return "jira:" + workspaceID + ":token"
}
// DefaultIssueWatchPollInterval is the polling cadence assigned to a watcher
// when the caller does not specify one. Five minutes balances freshness against
// Atlassian rate limits when many workspaces have watches configured.
const DefaultIssueWatchPollInterval = 300
// IssueWatch configures periodic JQL polling: a workspace-scoped watcher runs
// the JQL on a schedule and emits a NewJiraIssueEvent for each matching ticket
// the orchestrator hasn't already turned into a Kandev task.
//
// Unlike the GitHub equivalent, JIRA issues have no repository affinity — the
// target workflow step's defaults determine where the resulting task runs, so
// there's no `repos` column.
type IssueWatch struct {
ID string `json:"id" db:"id"`
WorkspaceID string `json:"workspaceId" db:"workspace_id"`
WorkflowID string `json:"workflowId" db:"workflow_id"`
WorkflowStepID string `json:"workflowStepId" db:"workflow_step_id"`
JQL string `json:"jql" db:"jql"`
AgentProfileID string `json:"agentProfileId" db:"agent_profile_id"`
ExecutorProfileID string `json:"executorProfileId" db:"executor_profile_id"`
Prompt string `json:"prompt" db:"prompt"`
Enabled bool `json:"enabled" db:"enabled"`
PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"`
LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
// IssueWatchTask deduplicates task creation per (watch, ticket) tuple. The
// UNIQUE constraint on (issue_watch_id, issue_key) prevents two concurrent
// pollers from racing to create duplicate tasks for the same ticket.
type IssueWatchTask struct {
ID string `json:"id" db:"id"`
IssueWatchID string `json:"issueWatchId" db:"issue_watch_id"`
IssueKey string `json:"issueKey" db:"issue_key"`
IssueURL string `json:"issueUrl" db:"issue_url"`
TaskID string `json:"taskId" db:"task_id"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
// NewJiraIssueEvent is published on the bus whenever the poller observes a
// ticket matching a watch that has no existing dedup row. The orchestrator
// consumes this to create (and optionally auto-start) a Kandev task.
type NewJiraIssueEvent struct {
IssueWatchID string `json:"issueWatchId"`
WorkspaceID string `json:"workspaceId"`
WorkflowID string `json:"workflowId"`
WorkflowStepID string `json:"workflowStepId"`
AgentProfileID string `json:"agentProfileId"`
ExecutorProfileID string `json:"executorProfileId"`
Prompt string `json:"prompt"`
Issue *JiraTicket `json:"issue"`
}
// CreateIssueWatchRequest is the payload for POST /api/v1/jira/watches/issue.
type CreateIssueWatchRequest struct {
WorkspaceID string `json:"workspaceId"`
WorkflowID string `json:"workflowId"`
WorkflowStepID string `json:"workflowStepId"`
JQL string `json:"jql"`
AgentProfileID string `json:"agentProfileId"`
ExecutorProfileID string `json:"executorProfileId"`
Prompt string `json:"prompt"`
PollIntervalSeconds int `json:"pollIntervalSeconds"`
Enabled *bool `json:"enabled,omitempty"`
}
// UpdateIssueWatchRequest is the payload for PATCH /api/v1/jira/watches/issue/:id.
// All fields are pointers so the caller can omit ones it doesn't want to change.
type UpdateIssueWatchRequest struct {
WorkflowID *string `json:"workflowId,omitempty"`
WorkflowStepID *string `json:"workflowStepId,omitempty"`
JQL *string `json:"jql,omitempty"`
AgentProfileID *string `json:"agentProfileId,omitempty"`
ExecutorProfileID *string `json:"executorProfileId,omitempty"`
Prompt *string `json:"prompt,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"`
}
+116 -14
View File
@@ -17,14 +17,26 @@ import (
// don't hammer Atlassian when many workspaces are configured.
const defaultAuthPollInterval = 90 * time.Second
// Poller probes the stored Jira credentials of every configured workspace on a
// fixed cadence and persists the result on the JiraConfig row. The frontend
// reads those fields via getJiraConfig to render the connected/disconnected
// indicator without doing its own probing.
// 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`,
// so this is the *minimum* granularity, not the *actual* cadence. 60 seconds
// matches the smallest interval the UI accepts; the gating check then rate-
// limits each individual watcher.
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.
// - issue watches: runs each enabled watcher's JQL and emits NewJiraIssueEvent
// for every matching ticket the orchestrator hasn't yet seen.
//
// Both loops are cancelled together via Stop.
type Poller struct {
service *Service
logger *logger.Logger
interval time.Duration
service *Service
logger *logger.Logger
authInterval time.Duration
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
@@ -33,12 +45,26 @@ type Poller struct {
started bool
}
// NewPoller returns a poller that uses the default 90s cadence.
// NewPoller returns a poller using the default cadences.
func NewPoller(svc *Service, log *logger.Logger) *Poller {
return &Poller{service: svc, logger: log, interval: defaultAuthPollInterval}
return &Poller{
service: svc,
logger: log,
authInterval: defaultAuthPollInterval,
issueInterval: defaultIssuePollTickInterval,
}
}
// Start launches the background loop. Calling Start more than once without
// SetIssueTickHook installs a callback fired at the end of each issue-watch
// tick. Production code never sets this; tests use it to wait for a tick
// without sleep-polling.
func (p *Poller) SetIssueTickHook(fn func()) {
p.mu.Lock()
p.issueTickHook = fn
p.mu.Unlock()
}
// Start launches both background loops. Calling Start more than once without
// Stop is a no-op.
func (p *Poller) Start(ctx context.Context) {
p.mu.Lock()
@@ -48,9 +74,10 @@ func (p *Poller) Start(ctx context.Context) {
}
p.started = true
ctx, p.cancel = context.WithCancel(ctx)
p.wg.Add(1)
p.wg.Add(2)
go p.loop(ctx)
p.logger.Info("Jira auth poller started")
go p.issueWatchLoop(ctx)
p.logger.Info("Jira poller started")
}
// Stop cancels the loop and waits for it to drain.
@@ -69,7 +96,7 @@ func (p *Poller) Stop() {
p.mu.Lock()
p.started = false
p.mu.Unlock()
p.logger.Info("Jira auth poller stopped")
p.logger.Info("Jira poller stopped")
}
func (p *Poller) loop(ctx context.Context) {
@@ -77,7 +104,7 @@ func (p *Poller) loop(ctx context.Context) {
// 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)
ticker := time.NewTicker(p.authInterval)
defer ticker.Stop()
for {
select {
@@ -89,6 +116,81 @@ func (p *Poller) loop(ctx context.Context) {
}
}
// issueWatchLoop drives the periodic JQL-poll → publish-event flow. Unlike
// the auth loop, this one waits a full interval before its first tick so the
// backend doesn't hammer JIRA the moment it starts.
func (p *Poller) issueWatchLoop(ctx context.Context) {
defer p.wg.Done()
ticker := time.NewTicker(p.issueInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.checkIssueWatches(ctx)
p.fireIssueTickHook()
}
}
}
func (p *Poller) checkIssueWatches(ctx context.Context) {
watches, err := p.service.Store().ListEnabledIssueWatches(ctx)
if err != nil {
p.logger.Warn("jira poller: list enabled issue watches failed", zap.Error(err))
return
}
if len(watches) == 0 {
return
}
for _, w := range watches {
if ctx.Err() != nil {
return
}
if !isIssueWatchDue(w, time.Now()) {
continue
}
newTickets, err := p.service.CheckIssueWatch(ctx, w)
if err != nil {
p.logger.Debug("jira poller: check issue watch failed",
zap.String("watch_id", w.ID), zap.Error(err))
continue
}
for _, t := range newTickets {
p.logger.Info("new jira issue found for watch",
zap.String("watch_id", w.ID),
zap.String("issue_key", t.Key),
zap.String("summary", t.Summary))
p.service.publishNewJiraIssueEvent(ctx, w, t)
}
}
}
// isIssueWatchDue reports whether enough time has passed since the watch was
// last polled to re-run its JQL on this tick. A watch with no LastPolledAt
// (never polled, or DB row freshly created) is always due. PollIntervalSeconds
// <= 0 falls back to the default — the same normalisation the store applies on
// write — so a corrupt row never blocks polling forever.
func isIssueWatchDue(w *IssueWatch, now time.Time) bool {
if w.LastPolledAt == nil {
return true
}
interval := w.PollIntervalSeconds
if interval <= 0 {
interval = DefaultIssueWatchPollInterval
}
return now.Sub(*w.LastPolledAt) >= time.Duration(interval)*time.Second
}
func (p *Poller) fireIssueTickHook() {
p.mu.Lock()
hook := p.issueTickHook
p.mu.Unlock()
if hook != nil {
hook()
}
}
func (p *Poller) probeAll(ctx context.Context) {
ids, err := p.service.Store().ListConfiguredWorkspaces(ctx)
if err != nil {
@@ -0,0 +1,214 @@
package jira
import (
"context"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
// recordingSubscriber subscribes to JiraNewIssue on the in-memory bus and
// captures payloads so the test can assert on them without touching internals.
type recordingSubscriber struct {
mu sync.Mutex
payload []*NewJiraIssueEvent
done chan struct{} // closed once `expected` events have arrived.
want int
}
func newRecordingSubscriber(eb bus.EventBus, want int) (*recordingSubscriber, error) {
r := &recordingSubscriber{done: make(chan struct{}), want: want}
_, err := eb.Subscribe(events.JiraNewIssue, func(_ context.Context, e *bus.Event) error {
r.mu.Lock()
defer r.mu.Unlock()
evt, ok := e.Data.(*NewJiraIssueEvent)
if !ok {
return nil
}
r.payload = append(r.payload, evt)
if r.want > 0 && len(r.payload) == r.want {
close(r.done)
}
return nil
})
return r, err
}
func (r *recordingSubscriber) snapshot() []*NewJiraIssueEvent {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*NewJiraIssueEvent, len(r.payload))
copy(out, r.payload)
return out
}
func TestPoller_CheckIssueWatches_PublishesNewTicketsOnly(t *testing.T) {
f := newPollerFixture(t)
ctx := context.Background()
f.saveConfig(t, "ws-1", "tok")
eb := bus.NewMemoryEventBus(logger.Default())
defer eb.Close()
f.svc.SetEventBus(eb)
sub, err := newRecordingSubscriber(eb, 2)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
w := newTestIssueWatch("ws-1")
if err := f.store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create watch: %v", err)
}
f.client.searchFn = func(_ string) (*SearchResult, error) {
return &SearchResult{
Tickets: []JiraTicket{
{Key: "PROJ-1", Summary: "first", URL: "https://x/browse/PROJ-1"},
{Key: "PROJ-2", Summary: "second", URL: "https://x/browse/PROJ-2"},
},
IsLast: true,
}, nil
}
// First tick: both tickets are new, so two events publish.
f.poller.checkIssueWatches(ctx)
<-sub.done // wait for both events to arrive on the in-memory bus
first := sub.snapshot()
if len(first) != 2 {
t.Fatalf("expected 2 events on first tick, got %d", len(first))
}
keys := []string{first[0].Issue.Key, first[1].Issue.Key}
if !contains(keys, "PROJ-1") || !contains(keys, "PROJ-2") {
t.Errorf("expected events for PROJ-1 and PROJ-2, got %v", keys)
}
if first[0].WorkspaceID != "ws-1" || first[0].WorkflowID != "wf-1" {
t.Errorf("event missing watch context: %+v", first[0])
}
// Reserve both keys to simulate the orchestrator having created tasks.
for _, key := range []string{"PROJ-1", "PROJ-2"} {
if _, err := f.store.ReserveIssueWatchTask(ctx, w.ID, key, "https://x/browse/"+key); err != nil {
t.Fatalf("reserve %s: %v", key, err)
}
}
// Second tick: nothing new, no additional events should publish.
f.poller.checkIssueWatches(ctx)
if got := len(sub.snapshot()); got != 2 {
t.Errorf("expected event count to stay at 2 after dedup, got %d", got)
}
}
func TestPoller_CheckIssueWatches_SkipsDisabled(t *testing.T) {
f := newPollerFixture(t)
ctx := context.Background()
f.saveConfig(t, "ws-1", "tok")
eb := bus.NewMemoryEventBus(logger.Default())
defer eb.Close()
f.svc.SetEventBus(eb)
disabled := newTestIssueWatch("ws-1")
disabled.Enabled = false
if err := f.store.CreateIssueWatch(ctx, disabled); err != nil {
t.Fatalf("create disabled: %v", err)
}
calls := 0
f.client.searchFn = func(_ string) (*SearchResult, error) {
calls++
return &SearchResult{Tickets: []JiraTicket{{Key: "X-1"}}, IsLast: true}, nil
}
f.poller.checkIssueWatches(ctx)
if calls != 0 {
t.Errorf("expected disabled watch to be skipped (no JIRA call), got %d calls", calls)
}
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}
func TestIsIssueWatchDue(t *testing.T) {
now := time.Now()
stamp := now.Add(-30 * time.Second)
cases := []struct {
name string
watch *IssueWatch
expected bool
}{
{
name: "never polled is always due",
watch: &IssueWatch{PollIntervalSeconds: 300},
expected: true,
},
{
name: "polled less than interval ago is not due",
watch: &IssueWatch{PollIntervalSeconds: 300, LastPolledAt: &stamp},
expected: false,
},
{
name: "polled at exactly the interval boundary is due",
watch: &IssueWatch{PollIntervalSeconds: 30, LastPolledAt: &stamp},
expected: true,
},
{
name: "zero interval falls back to default and gates accordingly",
watch: &IssueWatch{PollIntervalSeconds: 0, LastPolledAt: &stamp},
expected: false, // default 300s > 30s elapsed
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isIssueWatchDue(tc.watch, now); got != tc.expected {
t.Errorf("expected %v, got %v", tc.expected, got)
}
})
}
}
func TestPoller_CheckIssueWatches_RespectsPerWatchInterval(t *testing.T) {
f := newPollerFixture(t)
ctx := context.Background()
f.saveConfig(t, "ws-1", "tok")
eb := bus.NewMemoryEventBus(logger.Default())
defer eb.Close()
f.svc.SetEventBus(eb)
// Watch was just polled — should be skipped on this tick.
w := newTestIssueWatch("ws-1")
w.PollIntervalSeconds = 300
if err := f.store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Now().UTC()
if err := f.store.UpdateIssueWatchLastPolled(ctx, w.ID, now); err != nil {
t.Fatalf("stamp: %v", err)
}
calls := 0
f.client.searchFn = func(_ string) (*SearchResult, error) {
calls++
return &SearchResult{Tickets: []JiraTicket{{Key: "X-1"}}, IsLast: true}, nil
}
f.poller.checkIssueWatches(ctx)
if calls != 0 {
t.Errorf("expected gating to skip the JIRA search, got %d call(s)", calls)
}
}
+10 -4
View File
@@ -4,17 +4,23 @@ import (
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
)
// Provide builds the Jira service. Cleanup is a no-op today — the service
// holds only in-memory client caches — but the signature mirrors other
// integration providers so callers can register it uniformly.
func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) {
// Provide builds the Jira service. eventBus may be nil — used in tests and
// during early boot before the bus is ready; the service falls back to a
// no-op publish path. Cleanup is a no-op today — the service holds only
// in-memory client caches — but the signature mirrors other integration
// providers so callers can register it uniformly.
func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) {
store, err := NewStore(writer, reader)
if err != nil {
return nil, nil, err
}
svc := NewService(store, secrets, DefaultClientFactory, log)
if eventBus != nil {
svc.SetEventBus(eventBus)
}
cleanup := func() error { return nil }
return svc, cleanup, nil
}
+238
View File
@@ -11,6 +11,8 @@ import (
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
// SecretStore is the subset of the secrets store the service needs. Kept small
@@ -32,6 +34,7 @@ type Service struct {
clientFn ClientFactory
cache map[string]Client // workspaceID → client, cleared on config change.
probeHook func(workspaceID string)
eventBus bus.EventBus
}
// ClientFactory builds a Client for the given config + secret. Overridable so
@@ -404,3 +407,238 @@ func normalizeSiteURL(raw string) string {
}
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,
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)
}
// 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 := 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)
}
// 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, w.WorkspaceID)
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,
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
}
}
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
}
}
@@ -0,0 +1,220 @@
package jira
import (
"context"
"errors"
"testing"
)
// withSearchResults returns a fakeClient that always returns the given tickets
// for SearchTickets, ignoring the JQL.
func (c *fakeClient) withSearchResults(tickets []JiraTicket) *fakeClient {
c.searchFn = func(_ string) (*SearchResult, error) {
return &SearchResult{Tickets: tickets, IsLast: true}, nil
}
return c
}
func TestService_CreateIssueWatch_DefaultsAndValidation(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
// Missing JQL is rejected.
if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for missing JQL, got %v", err)
}
// Happy path assigns ID + defaults Enabled=true.
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
JQL: "project = PROJ",
})
if err != nil {
t.Fatalf("create: %v", err)
}
if w.ID == "" {
t.Fatal("expected ID assigned")
}
if !w.Enabled {
t.Error("expected Enabled defaulted to true")
}
}
func TestService_UpdateIssueWatch_PartialPatch(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
JQL: "project = PROJ",
Prompt: "original",
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Patch only the prompt; everything else must remain.
newPrompt := "updated"
updated, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{
Prompt: &newPrompt,
})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Prompt != "updated" {
t.Errorf("prompt not patched: %q", updated.Prompt)
}
if updated.JQL != created.JQL || updated.WorkspaceID != created.WorkspaceID {
t.Errorf("unexpected mutation of unset fields: %+v", updated)
}
// Patching JQL to empty is rejected to keep watch rows valid.
empty := " "
if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{JQL: &empty}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for empty JQL, got %v", err)
}
}
func TestService_CreateIssueWatch_RejectsOutOfRangeInterval(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
for _, n := range []int{1, 30, 59, 3601, 86400} {
_, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
JQL: "project = PROJ",
PollIntervalSeconds: n,
})
if !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for pollIntervalSeconds=%d, got %v", n, err)
}
}
// Zero is allowed (the store coerces to default).
if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
JQL: "project = PROJ",
}); err != nil {
t.Errorf("expected zero pollIntervalSeconds to be accepted (default fill), got %v", err)
}
}
func TestService_UpdateIssueWatch_RejectsEmptyWorkflowFields(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
JQL: "project = PROJ",
})
if err != nil {
t.Fatalf("create: %v", err)
}
empty := ""
// `{"workflowId": ""}` would land here — the nil guard in applyIssueWatchPatch
// doesn't catch explicit empty strings, and a watch with empty WorkflowID
// would silently drop every event the orchestrator tries to handle.
for _, req := range []*UpdateIssueWatchRequest{
{WorkflowID: &empty},
{WorkflowStepID: &empty},
} {
if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, req); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for %+v, got %v", req, err)
}
}
}
func TestService_UpdateIssueWatch_NotFound(t *testing.T) {
f := newSvcFixture(t)
prompt := "x"
_, err := f.svc.UpdateIssueWatch(context.Background(), "ghost", &UpdateIssueWatchRequest{Prompt: &prompt})
if !errors.Is(err, ErrIssueWatchNotFound) {
t.Errorf("expected ErrIssueWatchNotFound, got %v", err)
}
}
func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
// Configure JIRA so clientFor succeeds.
if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{
WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e",
AuthMethod: AuthMethodAPIToken, Secret: "tok",
}); err != nil {
t.Fatalf("set config: %v", err)
}
// Search returns three tickets; one of them is already in the dedup table.
f.client.withSearchResults([]JiraTicket{
{Key: "PROJ-1", Summary: "one", URL: "https://a.net/browse/PROJ-1"},
{Key: "PROJ-2", Summary: "two", URL: "https://a.net/browse/PROJ-2"},
{Key: "PROJ-3", Summary: "three", URL: "https://a.net/browse/PROJ-3"},
})
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
JQL: "project = PROJ",
})
if err != nil {
t.Fatalf("create watch: %v", err)
}
// Pre-seed PROJ-2 as already turned into a task.
if _, err := f.store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-2", "https://a.net/browse/PROJ-2"); err != nil {
t.Fatalf("seed reservation: %v", err)
}
got, err := f.svc.CheckIssueWatch(ctx, w)
if err != nil {
t.Fatalf("check: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 unseen tickets, got %d", len(got))
}
for _, tk := range got {
if tk.Key == "PROJ-2" {
t.Error("PROJ-2 should have been filtered as already seen")
}
}
// last_polled_at must have been stamped.
refreshed, _ := f.store.GetIssueWatch(ctx, w.ID)
if refreshed.LastPolledAt == nil {
t.Error("expected last_polled_at stamped after check")
}
}
func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{
WorkspaceID: "ws-1", SiteURL: "https://a.net", Email: "e",
AuthMethod: AuthMethodAPIToken, Secret: "tok",
}); err != nil {
t.Fatalf("set config: %v", err)
}
f.client.searchFn = func(_ string) (*SearchResult, error) {
return nil, errors.New("upstream 500")
}
w, _ := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
JQL: "project = PROJ",
})
if _, err := f.svc.CheckIssueWatch(ctx, w); err == nil {
t.Error("expected error from search to surface to caller")
}
refreshed, _ := f.store.GetIssueWatch(ctx, w.ID)
if refreshed.LastPolledAt == nil {
t.Error("expected last_polled_at stamped even on search failure (liveness signal)")
}
}
+5 -1
View File
@@ -58,6 +58,7 @@ type fakeClient struct {
getTicketFn func(key string) (*JiraTicket, error)
transitionFn func(key, id string) error
listProjects func() ([]JiraProject, error)
searchFn func(jql string) (*SearchResult, error)
transitionLog []string // "key:id"
}
@@ -89,7 +90,10 @@ func (c *fakeClient) ListProjects(_ context.Context) ([]JiraProject, error) {
}
return nil, nil
}
func (c *fakeClient) SearchTickets(_ context.Context, _, _ string, _ int) (*SearchResult, error) {
func (c *fakeClient) SearchTickets(_ context.Context, jql, _ string, _ int) (*SearchResult, error) {
if c.searchFn != nil {
return c.searchFn(jql)
}
return &SearchResult{}, nil
}
+212
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
@@ -39,6 +40,35 @@ const createTablesSQL = `
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS jira_issue_watches (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
workflow_id TEXT NOT NULL,
workflow_step_id TEXT NOT NULL,
jql TEXT NOT NULL,
agent_profile_id TEXT NOT NULL DEFAULT '',
executor_profile_id TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT 1,
poll_interval_seconds INTEGER NOT NULL DEFAULT 300,
last_polled_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_jira_issue_watches_workspace
ON jira_issue_watches(workspace_id);
CREATE TABLE IF NOT EXISTS jira_issue_watch_tasks (
id TEXT PRIMARY KEY,
issue_watch_id TEXT NOT NULL,
issue_key TEXT NOT NULL,
issue_url TEXT NOT NULL,
task_id TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
UNIQUE(issue_watch_id, issue_key),
FOREIGN KEY(issue_watch_id) REFERENCES jira_issue_watches(id) ON DELETE CASCADE
);
`
// addedColumns lists columns introduced after the initial schema. SQLite has no
@@ -175,3 +205,185 @@ func (s *Store) UpdateAuthHealth(ctx context.Context, workspaceID string, ok boo
checkedAt, ok, errMsg, workspaceID)
return err
}
// --- Issue watch operations ---
const issueWatchColumns = `id, workspace_id, workflow_id, workflow_step_id, jql,
agent_profile_id, executor_profile_id, prompt, enabled,
poll_interval_seconds, last_polled_at, created_at, updated_at`
// CreateIssueWatch persists a new issue watch row. ID and timestamps are
// assigned here so callers can pass a partially-populated struct.
func (s *Store) CreateIssueWatch(ctx context.Context, w *IssueWatch) error {
if w.ID == "" {
w.ID = uuid.New().String()
}
now := time.Now().UTC()
w.CreatedAt = now
w.UpdatedAt = now
if w.PollIntervalSeconds <= 0 {
w.PollIntervalSeconds = DefaultIssueWatchPollInterval
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO jira_issue_watches (`+issueWatchColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
w.ID, w.WorkspaceID, w.WorkflowID, w.WorkflowStepID, w.JQL,
w.AgentProfileID, w.ExecutorProfileID, w.Prompt, w.Enabled,
w.PollIntervalSeconds, w.LastPolledAt, w.CreatedAt, w.UpdatedAt)
return err
}
// GetIssueWatch returns a single watch by ID, or nil when no row matches.
func (s *Store) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) {
var w IssueWatch
err := s.ro.GetContext(ctx, &w,
`SELECT `+issueWatchColumns+` FROM jira_issue_watches WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &w, nil
}
// ListIssueWatches returns all watches configured for a workspace, in
// insertion order. The UI uses this to render the watcher table.
func (s *Store) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) {
var watches []*IssueWatch
err := s.ro.SelectContext(ctx, &watches,
`SELECT `+issueWatchColumns+` FROM jira_issue_watches
WHERE workspace_id = ? ORDER BY created_at`, workspaceID)
if err != nil {
return nil, err
}
return watches, nil
}
// ListEnabledIssueWatches returns every enabled watch across all workspaces,
// used by the poller to decide what to query each tick.
func (s *Store) ListEnabledIssueWatches(ctx context.Context) ([]*IssueWatch, error) {
var watches []*IssueWatch
err := s.ro.SelectContext(ctx, &watches,
`SELECT `+issueWatchColumns+` FROM jira_issue_watches
WHERE enabled = 1 ORDER BY created_at`)
if err != nil {
return nil, err
}
return watches, nil
}
// UpdateIssueWatch overwrites the mutable fields of an existing watch row.
// updated_at is bumped automatically; last_polled_at is preserved unless the
// caller explicitly sets it.
func (s *Store) UpdateIssueWatch(ctx context.Context, w *IssueWatch) error {
w.UpdatedAt = time.Now().UTC()
if w.PollIntervalSeconds <= 0 {
w.PollIntervalSeconds = DefaultIssueWatchPollInterval
}
_, err := s.db.ExecContext(ctx, `
UPDATE jira_issue_watches SET workflow_id = ?, workflow_step_id = ?, jql = ?,
agent_profile_id = ?, executor_profile_id = ?, prompt = ?,
enabled = ?, poll_interval_seconds = ?, last_polled_at = ?, updated_at = ?
WHERE id = ?`,
w.WorkflowID, w.WorkflowStepID, w.JQL,
w.AgentProfileID, w.ExecutorProfileID, w.Prompt,
w.Enabled, w.PollIntervalSeconds, w.LastPolledAt, w.UpdatedAt, w.ID)
return err
}
// UpdateIssueWatchLastPolled stamps the last-polled timestamp without touching
// the rest of the row. The poller calls this after every check so the UI can
// show "polled X seconds ago".
func (s *Store) UpdateIssueWatchLastPolled(ctx context.Context, id string, t time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE jira_issue_watches SET last_polled_at = ?, updated_at = ? WHERE id = ?`,
t, time.Now().UTC(), id)
return err
}
// DeleteIssueWatch removes a watch and (via FK ON DELETE CASCADE) its dedup
// rows in a single transaction. The explicit DELETE on the child table guards
// older databases where foreign_keys may not have been enabled at attach time.
func (s *Store) DeleteIssueWatch(ctx context.Context, id string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM jira_issue_watch_tasks WHERE issue_watch_id = ?`, id); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM jira_issue_watches WHERE id = ?`, id); err != nil {
return err
}
return tx.Commit()
}
// ReserveIssueWatchTask atomically claims a slot for a (watch, ticket) pair via
// INSERT OR IGNORE. Returns true when this caller won the race and should
// proceed to create the task. False either means another handler already
// reserved the same ticket or the row already exists from a prior run.
func (s *Store) ReserveIssueWatchTask(ctx context.Context, watchID, issueKey, issueURL string) (bool, error) {
res, err := s.db.ExecContext(ctx, `
INSERT OR IGNORE INTO jira_issue_watch_tasks (id, issue_watch_id, issue_key, issue_url, task_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
uuid.New().String(), watchID, issueKey, issueURL, "", time.Now().UTC())
if err != nil {
return false, err
}
rows, err := res.RowsAffected()
if err != nil {
return false, err
}
return rows == 1, nil
}
// AssignIssueWatchTaskID stamps the created task ID onto a previously-reserved
// dedup row. Returns an error if no reservation matches — callers should treat
// that as a programming bug since they only call this after a successful
// reservation.
func (s *Store) AssignIssueWatchTaskID(ctx context.Context, watchID, issueKey, taskID string) error {
res, err := s.db.ExecContext(ctx, `
UPDATE jira_issue_watch_tasks SET task_id = ?
WHERE issue_watch_id = ? AND issue_key = ?`,
taskID, watchID, issueKey)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("assign task ID: reservation row not found for watch=%s issue=%s", watchID, issueKey)
}
return nil
}
// ReleaseIssueWatchTask drops a reservation so the next poll can retry. Used
// when task creation fails after a successful reserve.
func (s *Store) ReleaseIssueWatchTask(ctx context.Context, watchID, issueKey string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM jira_issue_watch_tasks WHERE issue_watch_id = ? AND issue_key = ?`,
watchID, issueKey)
return err
}
// ListSeenIssueKeys returns the set of ticket keys already reserved against a
// watch. Returning a set in one query is cheaper than per-ticket existence
// checks — a single JQL search can return up to 50 tickets per tick, so the
// per-call savings scale with the workspace's watch count.
func (s *Store) ListSeenIssueKeys(ctx context.Context, watchID string) (map[string]struct{}, error) {
var keys []string
err := s.ro.SelectContext(ctx, &keys,
`SELECT issue_key FROM jira_issue_watch_tasks WHERE issue_watch_id = ?`, watchID)
if err != nil {
return nil, err
}
out := make(map[string]struct{}, len(keys))
for _, k := range keys {
out[k] = struct{}{}
}
return out, nil
}
@@ -0,0 +1,292 @@
package jira
import (
"context"
"testing"
"time"
)
func newTestIssueWatch(workspaceID string) *IssueWatch {
return &IssueWatch{
WorkspaceID: workspaceID,
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
JQL: `project = PROJ AND status = "Open"`,
Prompt: "Investigate {{issue.key}}",
Enabled: true,
}
}
func TestStore_IssueWatch_CreateGet(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
w := newTestIssueWatch("ws-1")
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
if w.ID == "" {
t.Fatal("expected ID assigned")
}
if w.PollIntervalSeconds != DefaultIssueWatchPollInterval {
t.Errorf("expected default poll interval %d, got %d", DefaultIssueWatchPollInterval, w.PollIntervalSeconds)
}
if w.CreatedAt.IsZero() || w.UpdatedAt.IsZero() {
t.Error("expected timestamps assigned")
}
got, err := store.GetIssueWatch(ctx, w.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected watch, got nil")
}
if got.JQL != w.JQL || got.WorkspaceID != w.WorkspaceID || got.Prompt != w.Prompt {
t.Errorf("round-trip mismatch: %+v vs %+v", got, w)
}
}
func TestStore_IssueWatch_ListByWorkspace(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
w1 := newTestIssueWatch("ws-1")
w1.JQL = "project = A"
w2 := newTestIssueWatch("ws-1")
w2.JQL = "project = B"
w3 := newTestIssueWatch("ws-2")
for _, w := range []*IssueWatch{w1, w2, w3} {
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
}
got, err := store.ListIssueWatches(ctx, "ws-1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 watches for ws-1, got %d", len(got))
}
// ws-2's watch must not appear.
for _, w := range got {
if w.WorkspaceID != "ws-1" {
t.Errorf("workspace leaked into list: %s", w.WorkspaceID)
}
}
}
func TestStore_IssueWatch_ListEnabled(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
enabled := newTestIssueWatch("ws-1")
disabled := newTestIssueWatch("ws-2")
disabled.Enabled = false
for _, w := range []*IssueWatch{enabled, disabled} {
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
}
got, err := store.ListEnabledIssueWatches(ctx)
if err != nil {
t.Fatalf("list enabled: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected only the enabled watch, got %d", len(got))
}
if got[0].ID != enabled.ID {
t.Errorf("expected enabled watch returned, got %s", got[0].ID)
}
}
func TestStore_IssueWatch_Update(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
w := newTestIssueWatch("ws-1")
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
originalCreated := w.CreatedAt
w.JQL = "project = NEW"
w.Enabled = false
w.PollIntervalSeconds = 60
if err := store.UpdateIssueWatch(ctx, w); err != nil {
t.Fatalf("update: %v", err)
}
got, _ := store.GetIssueWatch(ctx, w.ID)
if got.JQL != "project = NEW" || got.Enabled || got.PollIntervalSeconds != 60 {
t.Errorf("update did not persist: %+v", got)
}
if !got.CreatedAt.Equal(originalCreated) {
t.Errorf("update must not change created_at: %v vs %v", got.CreatedAt, originalCreated)
}
}
func TestStore_IssueWatch_LastPolledStamp(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
w := newTestIssueWatch("ws-1")
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
t1 := time.Now().UTC().Truncate(time.Second)
if err := store.UpdateIssueWatchLastPolled(ctx, w.ID, t1); err != nil {
t.Fatalf("stamp: %v", err)
}
got, _ := store.GetIssueWatch(ctx, w.ID)
if got.LastPolledAt == nil || !got.LastPolledAt.Equal(t1) {
t.Errorf("expected last_polled_at=%v, got %v", t1, got.LastPolledAt)
}
}
func TestStore_IssueWatch_DeleteCascadesDedupRows(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
w := newTestIssueWatch("ws-1")
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://example.atlassian.net/browse/PROJ-1"); err != nil {
t.Fatalf("reserve: %v", err)
}
if err := store.DeleteIssueWatch(ctx, w.ID); err != nil {
t.Fatalf("delete: %v", err)
}
gone, _ := store.GetIssueWatch(ctx, w.ID)
if gone != nil {
t.Errorf("expected watch deleted, got %+v", gone)
}
// Dedup row must be gone too — re-creating a watch with the same ID and
// reserving the same key should succeed without a UNIQUE collision.
w2 := newTestIssueWatch("ws-1")
w2.ID = w.ID
if err := store.CreateIssueWatch(ctx, w2); err != nil {
t.Fatalf("recreate watch: %v", err)
}
ok, err := store.ReserveIssueWatchTask(ctx, w2.ID, "PROJ-1", "https://example.atlassian.net/browse/PROJ-1")
if err != nil {
t.Fatalf("re-reserve: %v", err)
}
if !ok {
t.Error("expected re-reserve to succeed after cascade delete")
}
}
func TestStore_IssueWatchTask_ReserveDedup(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
w := newTestIssueWatch("ws-1")
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
// First reservation wins.
first, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-7", "https://example.atlassian.net/browse/PROJ-7")
if err != nil {
t.Fatalf("first reserve: %v", err)
}
if !first {
t.Error("expected first reservation to win")
}
// Second reservation for the same (watch, key) loses (dedup).
second, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-7", "https://example.atlassian.net/browse/PROJ-7")
if err != nil {
t.Fatalf("second reserve: %v", err)
}
if second {
t.Error("expected second reservation to lose due to UNIQUE constraint")
}
// ListSeenIssueKeys reflects the reservation regardless of who won.
seen, err := store.ListSeenIssueKeys(ctx, w.ID)
if err != nil {
t.Fatalf("list seen: %v", err)
}
if _, ok := seen["PROJ-7"]; !ok {
t.Error("expected PROJ-7 in seen set after reservation")
}
}
func TestStore_IssueWatchTask_AssignTaskID(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
w := newTestIssueWatch("ws-1")
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://example.atlassian.net/browse/PROJ-1"); err != nil {
t.Fatalf("reserve: %v", err)
}
if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-1", "task-abc"); err != nil {
t.Fatalf("assign: %v", err)
}
// Assigning to a missing reservation surfaces a clear error.
err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-NOPE", "task-zzz")
if err == nil {
t.Error("expected error for missing reservation, got nil")
}
}
func TestStore_IssueWatchTask_ListSeenIssueKeys(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
w := newTestIssueWatch("ws-1")
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
for _, k := range []string{"PROJ-1", "PROJ-2", "PROJ-3"} {
if _, err := store.ReserveIssueWatchTask(ctx, w.ID, k, "https://x/"+k); err != nil {
t.Fatalf("reserve %s: %v", k, err)
}
}
seen, err := store.ListSeenIssueKeys(ctx, w.ID)
if err != nil {
t.Fatalf("list seen: %v", err)
}
for _, k := range []string{"PROJ-1", "PROJ-2", "PROJ-3"} {
if _, ok := seen[k]; !ok {
t.Errorf("expected %s in seen set, got %v", k, seen)
}
}
if _, ok := seen["PROJ-99"]; ok {
t.Error("expected PROJ-99 not in seen set")
}
}
func TestStore_IssueWatchTask_Release(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
w := newTestIssueWatch("ws-1")
if err := store.CreateIssueWatch(ctx, w); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://example.atlassian.net/browse/PROJ-1"); err != nil {
t.Fatalf("reserve: %v", err)
}
if err := store.ReleaseIssueWatchTask(ctx, w.ID, "PROJ-1"); err != nil {
t.Fatalf("release: %v", err)
}
// After release, the slot can be reserved again.
again, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://example.atlassian.net/browse/PROJ-1")
if err != nil {
t.Fatalf("re-reserve: %v", err)
}
if !again {
t.Error("expected reservation to succeed after release")
}
}
@@ -0,0 +1,185 @@
package orchestrator
import (
"context"
"fmt"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/jira"
)
// JiraService is the subset of jira.Service the orchestrator needs to
// deduplicate ticket→task mappings. Mirrors the GitHub equivalent so the
// orchestrator stays decoupled from the concrete jira package types.
type JiraService interface {
ReserveIssueWatchTask(ctx context.Context, watchID, issueKey, issueURL string) (bool, error)
AssignIssueWatchTaskID(ctx context.Context, watchID, issueKey, taskID string) error
ReleaseIssueWatchTask(ctx context.Context, watchID, issueKey string) error
}
// SetJiraService wires the JIRA dedup helpers into the orchestrator so
// jira-watch handlers can claim ticket slots before creating tasks.
func (s *Service) SetJiraService(j JiraService) {
s.jiraService = j
}
// subscribeJiraEvents wires the JIRA event handlers onto the bus. Called from
// the existing subscribeGitHubEvents-style boot path so all integration
// subscriptions stay grouped together.
func (s *Service) subscribeJiraEvents() {
if s.eventBus == nil {
return
}
if _, err := s.eventBus.Subscribe(events.JiraNewIssue, s.handleNewJiraIssue); err != nil {
s.logger.Error("failed to subscribe to jira.new_issue events", zap.Error(err))
}
}
// handleNewJiraIssue creates a Kandev task for a freshly-observed JIRA ticket.
// Reuses the same IssueTaskCreator the GitHub flow uses so task creation logic
// (workflow placement, on-enter actions, etc.) stays in one place.
func (s *Service) handleNewJiraIssue(_ context.Context, event *bus.Event) error {
evt, ok := event.Data.(*jira.NewJiraIssueEvent)
if !ok {
return nil
}
if evt.Issue == nil {
return nil
}
s.logger.Info("new jira issue detected from watch",
zap.String("issue_watch_id", evt.IssueWatchID),
zap.String("issue_key", evt.Issue.Key))
if s.issueTaskCreator == nil {
s.logger.Warn("issue task creator not configured, skipping jira task creation")
return nil
}
// Background context: the bus delivery context may be cancelled before
// task creation finishes. The work is independent of the publisher.
go s.createJiraIssueTask(context.Background(), evt)
return nil
}
func (s *Service) createJiraIssueTask(ctx context.Context, evt *jira.NewJiraIssueEvent) {
ticket := evt.Issue
if !s.reserveJiraIssue(ctx, evt) {
return
}
req := &IssueTaskRequest{
WorkspaceID: evt.WorkspaceID,
WorkflowID: evt.WorkflowID,
WorkflowStepID: evt.WorkflowStepID,
Title: fmt.Sprintf("[%s] %s", ticket.Key, ticket.Summary),
Description: interpolateJiraPrompt(evt.Prompt, ticket),
Metadata: map[string]interface{}{
"jira_issue_watch_id": evt.IssueWatchID,
"jira_issue_key": ticket.Key,
"jira_issue_url": ticket.URL,
"jira_status": ticket.StatusName,
"jira_assignee": ticket.AssigneeName,
"agent_profile_id": evt.AgentProfileID,
"executor_profile_id": evt.ExecutorProfileID,
},
}
task, err := s.issueTaskCreator.CreateIssueTask(ctx, req)
if err != nil {
s.logger.Error("failed to create jira issue task",
zap.String("issue_watch_id", evt.IssueWatchID),
zap.String("issue_key", ticket.Key),
zap.Error(err))
s.releaseJiraIssue(ctx, evt)
return
}
s.attachJiraIssueTaskID(ctx, evt, task.ID)
s.logger.Info("created jira issue task",
zap.String("task_id", task.ID),
zap.String("issue_key", ticket.Key))
if !s.shouldAutoStartStep(ctx, evt.WorkflowStepID) {
return
}
if _, err := s.StartTask(
ctx, task.ID, evt.AgentProfileID, "", evt.ExecutorProfileID,
0, task.Description, evt.WorkflowStepID, false, nil,
); err != nil {
s.logger.Error("failed to auto-start jira issue task",
zap.String("task_id", task.ID), zap.Error(err))
return
}
s.logger.Info("auto-started jira issue task",
zap.String("task_id", task.ID),
zap.String("issue_key", ticket.Key))
}
// reserveJiraIssue claims the dedup slot before the (relatively expensive)
// task creation. Returns false if another handler beat us to it. When the
// jira service isn't wired (boot order corner case), proceed anyway — better
// to risk a duplicate task than silently drop the event.
func (s *Service) reserveJiraIssue(ctx context.Context, evt *jira.NewJiraIssueEvent) bool {
if s.jiraService == nil {
return true
}
reserved, err := s.jiraService.ReserveIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Key, evt.Issue.URL)
if err != nil {
s.logger.Error("failed to reserve jira issue slot",
zap.String("issue_key", evt.Issue.Key), zap.Error(err))
return false
}
if !reserved {
s.logger.Debug("jira issue already reserved by concurrent handler, skipping",
zap.String("issue_key", evt.Issue.Key))
}
return reserved
}
func (s *Service) releaseJiraIssue(ctx context.Context, evt *jira.NewJiraIssueEvent) {
if s.jiraService == nil {
return
}
if err := s.jiraService.ReleaseIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Key); err != nil {
s.logger.Warn("failed to release jira issue reservation after task-create failure",
zap.String("issue_key", evt.Issue.Key), zap.Error(err))
}
}
func (s *Service) attachJiraIssueTaskID(ctx context.Context, evt *jira.NewJiraIssueEvent, taskID string) {
if s.jiraService == nil {
return
}
if err := s.jiraService.AssignIssueWatchTaskID(ctx, evt.IssueWatchID, evt.Issue.Key, taskID); err != nil {
s.logger.Error("failed to assign task ID to jira issue reservation",
zap.String("task_id", taskID),
zap.String("issue_key", evt.Issue.Key), zap.Error(err))
}
}
// interpolateJiraPrompt replaces {{issue.*}} placeholders with ticket fields.
// When the template is empty, the agent receives a minimal default that links
// to the JIRA ticket — enough context to start without forcing every watch to
// supply boilerplate.
func interpolateJiraPrompt(template string, t *jira.JiraTicket) string {
if strings.TrimSpace(template) == "" {
template = "Investigate JIRA ticket {{issue.key}}: {{issue.summary}}\n\n{{issue.url}}"
}
r := strings.NewReplacer(
"{{issue.key}}", t.Key,
"{{issue.summary}}", t.Summary,
"{{issue.url}}", t.URL,
"{{issue.status}}", t.StatusName,
"{{issue.priority}}", t.Priority,
"{{issue.type}}", t.IssueType,
"{{issue.assignee}}", t.AssigneeName,
"{{issue.reporter}}", t.ReporterName,
"{{issue.project}}", t.ProjectKey,
"{{issue.description}}", t.Description,
)
return r.Replace(template)
}
@@ -0,0 +1,159 @@
package orchestrator
import (
"context"
"errors"
"strings"
"testing"
"github.com/kandev/kandev/internal/jira"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
// mockJiraService records dedup calls so tests can assert on the
// reserve→assign→release contract used by handleNewJiraIssue.
type mockJiraService struct {
reserveReturn bool
reserveErr error
reserveCalls int
assignCalls int
releaseCalls int
lastWatchID string
lastIssueKey string
assignedTaskID string
}
func (m *mockJiraService) ReserveIssueWatchTask(_ context.Context, watchID, issueKey, _ string) (bool, error) {
m.reserveCalls++
m.lastWatchID = watchID
m.lastIssueKey = issueKey
return m.reserveReturn, m.reserveErr
}
func (m *mockJiraService) AssignIssueWatchTaskID(_ context.Context, _, _ string, taskID string) error {
m.assignCalls++
m.assignedTaskID = taskID
return nil
}
func (m *mockJiraService) ReleaseIssueWatchTask(_ context.Context, _, _ string) error {
m.releaseCalls++
return nil
}
func newJiraIssueEvent() *jira.NewJiraIssueEvent {
return &jira.NewJiraIssueEvent{
IssueWatchID: "iw-1",
WorkspaceID: "ws-1",
WorkflowID: "wf1",
WorkflowStepID: "step1",
Issue: &jira.JiraTicket{
Key: "PROJ-42",
Summary: "Login fails on mobile",
URL: "https://acme.atlassian.net/browse/PROJ-42",
},
}
}
func setupJiraTaskTest(t *testing.T) *Service {
t.Helper()
repo := setupTestRepo(t)
stepGetter := newMockStepGetter()
stepGetter.steps["step1"] = &wfmodels.WorkflowStep{
ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0,
Events: wfmodels.StepEvents{},
}
return createTestService(repo, stepGetter, newMockTaskRepo())
}
func TestCreateJiraIssueTask_HappyPath(t *testing.T) {
svc := setupJiraTaskTest(t)
jiraSvc := &mockJiraService{reserveReturn: true}
svc.SetJiraService(jiraSvc)
creator := &countingIssueTaskCreator{taskID: "task-jira-1"}
svc.SetIssueTaskCreator(creator)
svc.createJiraIssueTask(context.Background(), newJiraIssueEvent())
if jiraSvc.reserveCalls != 1 {
t.Errorf("expected 1 Reserve call, got %d", jiraSvc.reserveCalls)
}
if creator.calls != 1 {
t.Errorf("expected 1 CreateIssueTask call, got %d", creator.calls)
}
if jiraSvc.assignCalls != 1 {
t.Errorf("expected 1 AssignIssueWatchTaskID call, got %d", jiraSvc.assignCalls)
}
if jiraSvc.assignedTaskID != "task-jira-1" {
t.Errorf("expected assigned task id task-jira-1, got %q", jiraSvc.assignedTaskID)
}
if jiraSvc.releaseCalls != 0 {
t.Errorf("expected no Release on happy path, got %d", jiraSvc.releaseCalls)
}
if jiraSvc.lastIssueKey != "PROJ-42" {
t.Errorf("expected reservation keyed on PROJ-42, got %q", jiraSvc.lastIssueKey)
}
}
func TestCreateJiraIssueTask_SkipsWhenAlreadyReserved(t *testing.T) {
svc := setupJiraTaskTest(t)
jiraSvc := &mockJiraService{reserveReturn: false}
svc.SetJiraService(jiraSvc)
creator := &countingIssueTaskCreator{}
svc.SetIssueTaskCreator(creator)
svc.createJiraIssueTask(context.Background(), newJiraIssueEvent())
if creator.calls != 0 {
t.Errorf("expected CreateIssueTask NOT to be called when reservation is lost, got %d", creator.calls)
}
if jiraSvc.releaseCalls != 0 {
t.Errorf("expected no Release when reservation was never held, got %d", jiraSvc.releaseCalls)
}
}
func TestCreateJiraIssueTask_ReleasesWhenCreateFails(t *testing.T) {
svc := setupJiraTaskTest(t)
jiraSvc := &mockJiraService{reserveReturn: true}
svc.SetJiraService(jiraSvc)
creator := &countingIssueTaskCreator{err: errors.New("task creation failed")}
svc.SetIssueTaskCreator(creator)
svc.createJiraIssueTask(context.Background(), newJiraIssueEvent())
if jiraSvc.assignCalls != 0 {
t.Errorf("expected no Assign when task creation failed, got %d", jiraSvc.assignCalls)
}
if jiraSvc.releaseCalls != 1 {
t.Errorf("expected Release after task creation failure, got %d", jiraSvc.releaseCalls)
}
}
func TestInterpolateJiraPrompt(t *testing.T) {
ticket := &jira.JiraTicket{
Key: "PROJ-7",
Summary: "Login fails on mobile",
URL: "https://acme.atlassian.net/browse/PROJ-7",
StatusName: "In Progress",
Priority: "High",
IssueType: "Bug",
AssigneeName: "Alice",
ProjectKey: "PROJ",
}
// Empty template falls back to a default that mentions key + URL.
got := interpolateJiraPrompt("", ticket)
if !strings.Contains(got, "PROJ-7") || !strings.Contains(got, "https://acme.atlassian.net/browse/PROJ-7") {
t.Errorf("default template missing key or URL: %q", got)
}
// All placeholders.
got = interpolateJiraPrompt(
"{{issue.key}} | {{issue.summary}} | {{issue.status}} | {{issue.priority}} | {{issue.assignee}}",
ticket,
)
want := "PROJ-7 | Login fails on mobile | In Progress | High | Alice"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
@@ -217,6 +217,9 @@ type Service struct {
// Issue task creator for auto-creating tasks from issue watch events
issueTaskCreator IssueTaskCreator
// Jira service for issue watch dedup operations
jiraService JiraService
// Repository resolver for cloning + finding/creating repos for review tasks
repositoryResolver RepositoryResolver
@@ -637,6 +640,9 @@ func (s *Service) Start(ctx context.Context) error {
// Subscribe to GitHub integration events
s.subscribeGitHubEvents()
// Subscribe to JIRA integration events
s.subscribeJiraEvents()
// Subscribe to clarification events (cancel-and-resume flow)
s.subscribeClarificationEvents()
@@ -0,0 +1,400 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@kandev/ui/button";
import { Switch } from "@kandev/ui/switch";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@kandev/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Textarea } from "@kandev/ui/textarea";
import { useAppStore } from "@/components/state-provider";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { useWorkflows } from "@/hooks/use-workflows";
import { listWorkflowSteps } from "@/lib/api/domains/workflow-api";
import { searchJiraTickets } from "@/lib/api/domains/jira-api";
import type {
CreateJiraIssueWatchInput,
JiraIssueWatch,
UpdateJiraIssueWatchInput,
} from "@/lib/types/jira";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
watch: JiraIssueWatch | null;
workspaceId: string;
onCreate: (req: CreateJiraIssueWatchInput) => Promise<unknown>;
onUpdate: (id: string, req: UpdateJiraIssueWatchInput) => Promise<unknown>;
};
type FormState = {
jql: string;
workflowId: string;
workflowStepId: string;
agentProfileId: string;
executorProfileId: string;
prompt: string;
enabled: boolean;
pollInterval: number;
};
const DEFAULT_JQL = `project = PROJ AND status = "Open" ORDER BY created DESC`;
const DEFAULT_PROMPT = `Investigate JIRA ticket {{issue.key}}: {{issue.summary}}\n\n{{issue.url}}`;
const emptyForm: FormState = {
jql: DEFAULT_JQL,
workflowId: "",
workflowStepId: "",
agentProfileId: "",
executorProfileId: "",
prompt: DEFAULT_PROMPT,
enabled: true,
pollInterval: 300,
};
function formStateFromWatch(w: JiraIssueWatch): FormState {
return {
jql: w.jql,
workflowId: w.workflowId,
workflowStepId: w.workflowStepId,
agentProfileId: w.agentProfileId,
executorProfileId: w.executorProfileId,
prompt: w.prompt || DEFAULT_PROMPT,
enabled: w.enabled,
pollInterval: w.pollIntervalSeconds,
};
}
type StepOption = { id: string; name: string };
function useWorkflowSteps(workflowId: string) {
const [steps, setSteps] = useState<StepOption[]>([]);
useEffect(() => {
if (!workflowId) {
// Defer to next tick so we don't sync-set state inside an effect body.
void Promise.resolve().then(() => setSteps([]));
return;
}
let cancelled = false;
listWorkflowSteps(workflowId)
.then((res) => {
if (cancelled) return;
const sorted = [...res.steps].sort((a, b) => a.position - b.position);
setSteps(sorted.map((s) => ({ id: s.id, name: s.name })));
})
.catch(() => {
if (!cancelled) setSteps([]);
});
return () => {
cancelled = true;
};
}, [workflowId]);
return steps;
}
function useFormData(workspaceId: string) {
useSettingsData(true);
useWorkflows(workspaceId, true);
const workflows = useAppStore((s) => s.workflows.items);
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
const executors = useAppStore((s) => s.executors.items);
const allExecutorProfiles = useMemo(
() => executors.filter((e) => e.type !== "local").flatMap((e) => e.profiles ?? []),
[executors],
);
const filteredAgentProfiles = useMemo(
() => agentProfiles.filter((p) => !p.cli_passthrough),
[agentProfiles],
);
return { workflows, agentProfiles: filteredAgentProfiles, allExecutorProfiles };
}
function SelectField(props: {
label: string;
description?: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
items: { id: string; label: string }[];
}) {
return (
<div className="space-y-1.5">
<Label>{props.label}</Label>
{props.description && <p className="text-xs text-muted-foreground">{props.description}</p>}
<Select value={props.value || undefined} onValueChange={props.onChange}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent>
{props.items.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function JQLField({
workspaceId,
jql,
onChange,
}: {
workspaceId: string;
jql: string;
onChange: (v: string) => void;
}) {
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const [testing, setTesting] = useState(false);
const handleTest = useCallback(async () => {
setTesting(true);
setResult(null);
try {
const res = await searchJiraTickets(workspaceId, { jql, maxResults: 5 });
setResult({ ok: true, message: `Matched ${res.tickets.length} ticket(s) in this page.` });
} catch (err) {
setResult({ ok: false, message: `JQL error: ${String(err)}` });
} finally {
setTesting(false);
}
}, [workspaceId, jql]);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>JQL</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleTest}
disabled={!jql.trim() || testing}
className="cursor-pointer h-7"
>
{testing ? "Testing…" : "Test JQL"}
</Button>
</div>
<Textarea
value={jql}
onChange={(e) => onChange(e.target.value)}
placeholder='project = PROJ AND status = "Open"'
rows={3}
className="font-mono text-xs resize-y"
/>
<p className="text-xs text-muted-foreground">
Atlassian JQL. The watcher polls this query and creates one task per newly-matching ticket.
</p>
{result && (
<p className={`text-xs ${result.ok ? "text-emerald-600" : "text-destructive"}`}>
{result.message}
</p>
)}
</div>
);
}
function PromptField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<div className="space-y-1.5">
<Label>Task Prompt</Label>
<p className="text-xs text-muted-foreground">
Sent to the agent for each new ticket. Placeholders: {`{{issue.key}}`},{" "}
{`{{issue.summary}}`}, {`{{issue.url}}`}, {`{{issue.status}}`}, {`{{issue.priority}}`},{" "}
{`{{issue.type}}`}, {`{{issue.assignee}}`}, {`{{issue.reporter}}`}, {`{{issue.project}}`},{" "}
{`{{issue.description}}`}.
</p>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={5}
className="text-sm resize-y"
/>
</div>
);
}
function AutomationFields({
form,
setForm,
workspaceId,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
workspaceId: string;
}) {
const { workflows, agentProfiles, allExecutorProfiles } = useFormData(workspaceId);
const steps = useWorkflowSteps(form.workflowId);
return (
<>
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Workflow"
description="Tasks are created in this workflow."
value={form.workflowId}
onChange={(v) => setForm((p) => ({ ...p, workflowId: v, workflowStepId: "" }))}
placeholder="Select workflow"
items={workflows.map((w) => ({ id: w.id, label: w.name }))}
/>
<SelectField
label="Workflow Step"
description="Initial step for new tasks."
value={form.workflowStepId}
onChange={(v) => setForm((p) => ({ ...p, workflowStepId: v }))}
placeholder={form.workflowId ? "Select step" : "Select a workflow first"}
items={steps.map((s) => ({ id: s.id, label: s.name }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Agent Profile"
description="Optional — falls back to step default."
value={form.agentProfileId}
onChange={(v) => setForm((p) => ({ ...p, agentProfileId: v }))}
placeholder="(use step default)"
items={agentProfiles.map((p) => ({ id: p.id, label: p.label }))}
/>
<SelectField
label="Executor Profile"
description="Optional — falls back to step default."
value={form.executorProfileId}
onChange={(v) => setForm((p) => ({ ...p, executorProfileId: v }))}
placeholder="(use step default)"
items={allExecutorProfiles.map((p) => ({ id: p.id, label: p.name }))}
/>
</div>
</>
);
}
function SettingsFields({
form,
setForm,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
}) {
return (
<>
<div className="space-y-1.5">
<Label>Poll Interval (seconds)</Label>
<p className="text-xs text-muted-foreground">
How often to re-run the JQL. Minimum 60s, maximum 3600s.
</p>
<Input
type="number"
value={form.pollInterval}
onChange={(e) => setForm((p) => ({ ...p, pollInterval: Number(e.target.value) }))}
min={60}
max={3600}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Enabled</Label>
<p className="text-xs text-muted-foreground">Pause or resume polling.</p>
</div>
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm((p) => ({ ...p, enabled: v }))}
className="cursor-pointer"
/>
</div>
</>
);
}
function savingLabel(saving: boolean, isEdit: boolean): string {
if (saving) return "Saving…";
return isEdit ? "Update" : "Create";
}
export function JiraIssueWatchDialog({
open,
onOpenChange,
watch,
workspaceId,
onCreate,
onUpdate,
}: Props) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
useEffect(() => {
setForm(watch ? formStateFromWatch(watch) : emptyForm);
}, [watch, open]);
const canSave =
!!form.jql.trim() && !!form.workflowId && !!form.workflowStepId && !!form.prompt.trim();
const handleSave = useCallback(async () => {
setSaving(true);
try {
const payload = {
jql: form.jql,
workflowId: form.workflowId,
workflowStepId: form.workflowStepId,
agentProfileId: form.agentProfileId,
executorProfileId: form.executorProfileId,
prompt: form.prompt,
enabled: form.enabled,
pollIntervalSeconds: form.pollInterval,
};
if (watch) {
await onUpdate(watch.id, payload);
} else {
await onCreate({ ...payload, workspaceId });
}
onOpenChange(false);
} catch {
// Error surfaced by caller's toast.
} finally {
setSaving(false);
}
}, [form, watch, workspaceId, onCreate, onUpdate, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-full sm:w-[800px] sm:max-w-none max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{watch ? "Edit JIRA Watcher" : "Create JIRA Watcher"}</DialogTitle>
<DialogDescription>
Poll a JQL query and auto-create a Kandev task for each newly-matching ticket. Tickets
are not bound to a repository — the workflow step&apos;s defaults decide where the task
runs.
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<JQLField
workspaceId={workspaceId}
jql={form.jql}
onChange={(v) => setForm((p) => ({ ...p, jql: v }))}
/>
<AutomationFields form={form} setForm={setForm} workspaceId={workspaceId} />
<PromptField
value={form.prompt}
onChange={(v) => setForm((p) => ({ ...p, prompt: v }))}
/>
<SettingsFields form={form} setForm={setForm} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !canSave} className="cursor-pointer">
{savingLabel(saving, !!watch)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,156 @@
"use client";
import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { JiraIssueWatch } from "@/lib/types/jira";
type JiraIssueWatchTableProps = {
watches: JiraIssueWatch[];
onEdit: (watch: JiraIssueWatch) => void;
onDelete: (id: string) => void;
onTrigger: (id: string) => void;
onToggleEnabled: (watch: JiraIssueWatch) => void;
};
function formatLastPolled(dateStr?: string | null): string {
if (!dateStr) return "Never";
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function WatchActions({
watch,
onToggleEnabled,
onTrigger,
onDelete,
}: {
watch: JiraIssueWatch;
onToggleEnabled: (watch: JiraIssueWatch) => void;
onTrigger: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onToggleEnabled(watch);
}}
>
{watch.enabled ? (
<IconPlayerPause className="h-3.5 w-3.5" />
) : (
<IconPlayerPlay className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{watch.enabled ? "Pause" : "Enable"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
// Toast is fired by the parent's wrappedTrigger on completion —
// the inline "Checking…" toast it used to fire here was a duplicate.
onTrigger(watch.id);
}}
>
<IconRefresh className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Check now</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete(watch.id);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
);
}
export function JiraIssueWatchTable({
watches,
onEdit,
onDelete,
onTrigger,
onToggleEnabled,
}: JiraIssueWatchTableProps) {
if (watches.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
No JIRA watchers configured. Create one to auto-create tasks from JQL queries.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>JQL</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Last Polled</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{watches.map((watch) => (
<TableRow key={watch.id} className="cursor-pointer" onClick={() => onEdit(watch)}>
<TableCell className="font-mono text-xs max-w-md truncate" title={watch.jql}>
{watch.jql}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{Math.round(watch.pollIntervalSeconds / 60)}m
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatLastPolled(watch.lastPolledAt)}
</TableCell>
<TableCell>
<Badge variant={watch.enabled ? "default" : "secondary"} className="text-xs">
{watch.enabled ? "Active" : "Paused"}
</Badge>
</TableCell>
<TableCell className="text-right">
<WatchActions
watch={watch}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onDelete={onDelete}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,167 @@
"use client";
import { useCallback, useState } from "react";
import { IconBellRinging, IconPlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { SettingsSection } from "@/components/settings/settings-section";
import { useToast } from "@/components/toast-provider";
import { useJiraIssueWatches } from "@/hooks/domains/jira/use-jira-issue-watches";
import { JiraIssueWatchTable } from "./jira-issue-watch-table";
import { JiraIssueWatchDialog } from "./jira-issue-watch-dialog";
import type { JiraIssueWatch } from "@/lib/types/jira";
type Props = { workspaceId: string };
// RawActions is the slice of useJiraIssueWatches that this component needs to
// wrap with toasts. Pulled out so useToastedActions doesn't itself call the
// hook a second time on the same workspace — the parent passes its callbacks
// down, keeping a single fetch + single store subscription per render tree.
type RawActions = {
create: ReturnType<typeof useJiraIssueWatches>["create"];
update: ReturnType<typeof useJiraIssueWatches>["update"];
remove: ReturnType<typeof useJiraIssueWatches>["remove"];
trigger: ReturnType<typeof useJiraIssueWatches>["trigger"];
};
// useToastedActions wraps the raw create/update/delete/trigger callbacks with
// success/failure toasts. Kept separate so the section component stays under
// the 100-line lint limit and the toast wiring is testable in isolation.
function useToastedActions({ create, update, remove, trigger }: RawActions) {
const { toast } = useToast();
const wrappedCreate = useCallback(
async (req: Parameters<typeof create>[0]) => {
try {
await create(req);
toast({ description: "Watcher created", variant: "success" });
} catch (err) {
toast({ description: `Create failed: ${String(err)}`, variant: "error" });
throw err;
}
},
[create, toast],
);
const wrappedUpdate = useCallback(
async (id: string, req: Parameters<typeof update>[1]) => {
try {
await update(id, req);
toast({ description: "Watcher updated", variant: "success" });
} catch (err) {
toast({ description: `Update failed: ${String(err)}`, variant: "error" });
throw err;
}
},
[update, toast],
);
const wrappedDelete = useCallback(
async (id: string) => {
if (!confirm("Delete this JIRA watcher?")) return;
try {
await remove(id);
toast({ description: "Watcher deleted", variant: "success" });
} catch (err) {
toast({ description: `Delete failed: ${String(err)}`, variant: "error" });
}
},
[remove, toast],
);
const wrappedTrigger = useCallback(
async (id: string) => {
try {
const res = await trigger(id);
const n = res?.newIssues ?? 0;
const description =
n > 0
? `Found ${n} new ticket(s) — tasks will appear shortly.`
: "No new tickets matched.";
toast({ description, variant: "success" });
} catch (err) {
toast({ description: `Check failed: ${String(err)}`, variant: "error" });
}
},
[trigger, toast],
);
const toggleEnabled = useCallback(
async (w: JiraIssueWatch) => {
try {
await update(w.id, { enabled: !w.enabled });
} catch (err) {
toast({ description: `Toggle failed: ${String(err)}`, variant: "error" });
}
},
[update, toast],
);
return {
create: wrappedCreate,
update: wrappedUpdate,
remove: wrappedDelete,
trigger: wrappedTrigger,
toggleEnabled,
};
}
/**
* JiraIssueWatchersSection renders the "JIRA watchers" block on the JIRA
* settings page: a table of configured watchers, a "+ New" button, and a
* dialog for create/edit. Mirrors the GitHub issue-watch UI patterns.
*/
export function JiraIssueWatchersSection({ workspaceId }: Props) {
const { items, loading, create, update, remove, trigger } = useJiraIssueWatches(workspaceId);
const actions = useToastedActions({ create, update, remove, trigger });
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<JiraIssueWatch | null>(null);
const openCreate = useCallback(() => {
setEditing(null);
setDialogOpen(true);
}, []);
const openEdit = useCallback((w: JiraIssueWatch) => {
setEditing(w);
setDialogOpen(true);
}, []);
return (
<SettingsSection
icon={<IconBellRinging className="h-5 w-5" />}
title="JIRA watchers"
description="Poll a JQL query and auto-create a Kandev task for each newly-matching ticket."
action={
<Button size="sm" onClick={openCreate} className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-1" />
New watcher
</Button>
}
>
<Card>
<CardContent className="pt-6">
{loading && items.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">Loading…</p>
) : (
<JiraIssueWatchTable
watches={items}
onEdit={openEdit}
onDelete={actions.remove}
onTrigger={actions.trigger}
onToggleEnabled={actions.toggleEnabled}
/>
)}
</CardContent>
</Card>
<JiraIssueWatchDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
watch={editing}
workspaceId={workspaceId}
onCreate={actions.create}
onUpdate={actions.update}
/>
</SettingsSection>
);
}
@@ -14,6 +14,7 @@ import { Switch } from "@kandev/ui/switch";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { TaskPresetsSection } from "@/components/jira/task-presets-section";
import { JiraIssueWatchersSection } from "@/components/jira/jira-issue-watchers-section";
import { useJiraEnabled } from "@/components/jira/my-jira/use-jira-enabled";
import {
getJiraConfig,
@@ -561,6 +562,7 @@ export function JiraSettings({ workspaceId }: JiraSettingsProps) {
</CardContent>
</Card>
</SettingsSection>
{s.config?.hasSecret && <JiraIssueWatchersSection workspaceId={workspaceId} />}
<TaskPresetsSection />
</div>
);
@@ -0,0 +1,95 @@
"use client";
import { useEffect, useCallback, useRef } from "react";
import {
listJiraIssueWatches,
createJiraIssueWatch,
updateJiraIssueWatch,
deleteJiraIssueWatch,
triggerJiraIssueWatch,
} from "@/lib/api/domains/jira-api";
import { useAppStore } from "@/components/state-provider";
import type { CreateJiraIssueWatchInput, UpdateJiraIssueWatchInput } from "@/lib/types/jira";
/**
* useJiraIssueWatches owns the JIRA-watcher list for a workspace: it fetches
* once per workspace and exposes CRUD callbacks that mirror the GitHub
* useIssueWatches hook so UI components can be ported with minimal friction.
*
* The store's `loaded` flag is global, not workspace-scoped — so a `workspaceId`
* change (workspace switch, navigating back to settings) needs to reset the
* cached list before the fetch effect runs, otherwise the user sees the
* previous workspace's watchers stale-rendered.
*/
export function useJiraIssueWatches(workspaceId: string | null) {
const items = useAppStore((s) => s.jiraIssueWatches.items);
const loaded = useAppStore((s) => s.jiraIssueWatches.loaded);
const loading = useAppStore((s) => s.jiraIssueWatches.loading);
const setWatches = useAppStore((s) => s.setJiraIssueWatches);
const resetWatches = useAppStore((s) => s.resetJiraIssueWatches);
const setLoading = useAppStore((s) => s.setJiraIssueWatchesLoading);
const addWatch = useAppStore((s) => s.addJiraIssueWatch);
const updateWatch = useAppStore((s) => s.updateJiraIssueWatch);
const removeWatch = useAppStore((s) => s.removeJiraIssueWatch);
const lastWorkspaceId = useRef<string | null>(null);
useEffect(() => {
if (!workspaceId) return;
// Workspace changed — invalidate the cached list AND clear `loaded` so
// the fetch effect below re-runs against the new workspace. Calling
// `setWatches([])` here would be wrong: that action keeps `loaded=true`,
// and the fetch effect would short-circuit on the stale guard.
if (lastWorkspaceId.current !== null && lastWorkspaceId.current !== workspaceId) {
resetWatches();
}
lastWorkspaceId.current = workspaceId;
}, [workspaceId, resetWatches]);
useEffect(() => {
if (!workspaceId || loaded || loading) return;
setLoading(true);
listJiraIssueWatches(workspaceId, { cache: "no-store" })
.then((res) => setWatches(res ?? []))
.catch(() => setWatches([]))
.finally(() => setLoading(false));
}, [workspaceId, loaded, loading, setWatches, setLoading]);
const create = useCallback(
async (req: CreateJiraIssueWatchInput) => {
const watch = await createJiraIssueWatch(req);
addWatch(watch);
return watch;
},
[addWatch],
);
const update = useCallback(
async (id: string, req: UpdateJiraIssueWatchInput) => {
if (!workspaceId) throw new Error("workspaceId required");
const watch = await updateJiraIssueWatch(workspaceId, id, req);
updateWatch(watch);
return watch;
},
[workspaceId, updateWatch],
);
const remove = useCallback(
async (id: string) => {
if (!workspaceId) throw new Error("workspaceId required");
await deleteJiraIssueWatch(workspaceId, id);
removeWatch(id);
},
[workspaceId, removeWatch],
);
const trigger = useCallback(
async (id: string) => {
if (!workspaceId) throw new Error("workspaceId required");
return triggerJiraIssueWatch(workspaceId, id);
},
[workspaceId],
);
return { items, loaded, loading, create, update, remove, trigger };
}
+65
View File
@@ -1,11 +1,14 @@
import { fetchJson, type ApiRequestOptions } from "../client";
import type {
CreateJiraIssueWatchInput,
JiraConfig,
JiraIssueWatch,
JiraProject,
JiraSearchResult,
JiraTicket,
SetJiraConfigRequest,
TestJiraConnectionResult,
UpdateJiraIssueWatchInput,
} from "@/lib/types/jira";
// getJiraConfig returns null when the backend responds 204 (no config yet).
@@ -89,3 +92,65 @@ export async function transitionJiraTicket(
},
);
}
// --- Issue watches ---
export async function listJiraIssueWatches(workspaceId: string, options?: ApiRequestOptions) {
const res = await fetchJson<{ watches: JiraIssueWatch[] }>(
`/api/v1/jira/watches/issue?workspace_id=${encodeURIComponent(workspaceId)}`,
options,
);
return res.watches ?? [];
}
export async function createJiraIssueWatch(
payload: CreateJiraIssueWatchInput,
options?: ApiRequestOptions,
) {
return fetchJson<JiraIssueWatch>(`/api/v1/jira/watches/issue`, {
...options,
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) },
});
}
// All mutation/trigger endpoints require `workspace_id` so the backend can
// reject cross-workspace IDOR (a watch UUID from another workspace would
// otherwise be mutable by id alone). The list/create endpoints already scope
// by workspace; these now match.
export async function updateJiraIssueWatch(
workspaceId: string,
id: string,
payload: UpdateJiraIssueWatchInput,
options?: ApiRequestOptions,
) {
return fetchJson<JiraIssueWatch>(
`/api/v1/jira/watches/issue/${encodeURIComponent(id)}?workspace_id=${encodeURIComponent(workspaceId)}`,
{
...options,
init: { ...(options?.init ?? {}), method: "PATCH", body: JSON.stringify(payload) },
},
);
}
export async function deleteJiraIssueWatch(
workspaceId: string,
id: string,
options?: ApiRequestOptions,
) {
return fetchJson<{ deleted: boolean }>(
`/api/v1/jira/watches/issue/${encodeURIComponent(id)}?workspace_id=${encodeURIComponent(workspaceId)}`,
{ ...options, init: { ...(options?.init ?? {}), method: "DELETE" } },
);
}
export async function triggerJiraIssueWatch(
workspaceId: string,
id: string,
options?: ApiRequestOptions,
) {
return fetchJson<{ newIssues: number }>(
`/api/v1/jira/watches/issue/${encodeURIComponent(id)}/trigger?workspace_id=${encodeURIComponent(workspaceId)}`,
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } },
);
}
+3
View File
@@ -6,6 +6,7 @@ import {
defaultSessionRuntimeState,
defaultUIState,
defaultGitHubState,
defaultJiraState,
} from "./slices";
export const defaultState = {
@@ -62,6 +63,7 @@ export const defaultState = {
reviewWatches: defaultGitHubState.reviewWatches,
issueWatches: defaultGitHubState.issueWatches,
actionPresets: defaultGitHubState.actionPresets,
jiraIssueWatches: defaultJiraState.jiraIssueWatches,
previewPanel: defaultUIState.previewPanel,
rightPanel: defaultUIState.rightPanel,
diffs: defaultUIState.diffs,
@@ -144,6 +146,7 @@ export function mergeInitialState(initialState?: Partial<DefaultState>): Default
reviewWatches: { ...defaultState.reviewWatches, ...initialState.reviewWatches },
issueWatches: { ...defaultState.issueWatches, ...initialState.issueWatches },
actionPresets: { ...defaultState.actionPresets, ...initialState.actionPresets },
jiraIssueWatches: { ...defaultState.jiraIssueWatches, ...initialState.jiraIssueWatches },
previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel },
rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel },
diffs: { ...defaultState.diffs, ...initialState.diffs },
+7
View File
@@ -9,6 +9,7 @@ export {
} from "./session-runtime/session-runtime-slice";
export { createUISlice, defaultUIState } from "./ui/ui-slice";
export { createGitHubSlice, defaultGitHubState } from "./github/github-slice";
export { createJiraSlice, defaultJiraState } from "./jira/jira-slice";
// Export types
export type { KanbanSlice, KanbanSliceState, KanbanSliceActions } from "./kanban/types";
@@ -22,6 +23,12 @@ export type {
} from "./session-runtime/types";
export type { UISlice, UISliceState, UISliceActions } from "./ui/types";
export type { GitHubSlice, GitHubSliceState, GitHubSliceActions } from "./github/types";
export type {
JiraSlice,
JiraSliceState,
JiraSliceActions,
JiraIssueWatchesState,
} from "./jira/types";
// Re-export commonly used types from each domain
export type {
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createJiraSlice } from "./jira-slice";
import type { JiraSlice } from "./types";
import type { JiraIssueWatch } from "@/lib/types/jira";
function makeStore() {
return create<JiraSlice>()(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
immer((...a) => ({ ...(createJiraSlice as any)(...a) })),
);
}
function watch(id: string, overrides: Partial<JiraIssueWatch> = {}): JiraIssueWatch {
return {
id,
workspaceId: "ws-1",
workflowId: "wf-1",
workflowStepId: "step-1",
jql: "project = PROJ",
agentProfileId: "",
executorProfileId: "",
prompt: "",
enabled: true,
pollIntervalSeconds: 300,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("jira issue-watches slice", () => {
it("starts empty and not loaded", () => {
const store = makeStore();
const s = store.getState();
expect(s.jiraIssueWatches.items).toEqual([]);
expect(s.jiraIssueWatches.loaded).toBe(false);
expect(s.jiraIssueWatches.loading).toBe(false);
});
it("setJiraIssueWatches replaces items and flips loaded=true", () => {
const store = makeStore();
store.getState().setJiraIssueWatches([watch("a"), watch("b")]);
const s = store.getState();
expect(s.jiraIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]);
expect(s.jiraIssueWatches.loaded).toBe(true);
});
it("addJiraIssueWatch appends a new entry", () => {
const store = makeStore();
store.getState().setJiraIssueWatches([watch("a")]);
store.getState().addJiraIssueWatch(watch("b"));
expect(store.getState().jiraIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]);
});
it("updateJiraIssueWatch replaces in place by id; missing id is a no-op", () => {
const store = makeStore();
store.getState().setJiraIssueWatches([watch("a", { jql: "OLD" }), watch("b")]);
store.getState().updateJiraIssueWatch(watch("a", { jql: "NEW" }));
expect(store.getState().jiraIssueWatches.items[0].jql).toBe("NEW");
store.getState().updateJiraIssueWatch(watch("ghost", { jql: "X" }));
expect(store.getState().jiraIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]);
});
it("removeJiraIssueWatch filters by id", () => {
const store = makeStore();
store.getState().setJiraIssueWatches([watch("a"), watch("b"), watch("c")]);
store.getState().removeJiraIssueWatch("b");
expect(store.getState().jiraIssueWatches.items.map((w) => w.id)).toEqual(["a", "c"]);
});
it("resetJiraIssueWatches clears items AND loaded so a refetch is triggered", () => {
// The whole point of this action vs. setJiraIssueWatches([]) — an empty
// setWatches keeps loaded=true and would block the fetch effect from
// re-running on workspace switch.
const store = makeStore();
store.getState().setJiraIssueWatches([watch("a")]);
expect(store.getState().jiraIssueWatches.loaded).toBe(true);
store.getState().resetJiraIssueWatches();
expect(store.getState().jiraIssueWatches.items).toEqual([]);
expect(store.getState().jiraIssueWatches.loaded).toBe(false);
});
});
@@ -0,0 +1,43 @@
import type { StateCreator } from "zustand";
import type { JiraSlice, JiraSliceState } from "./types";
export const defaultJiraState: JiraSliceState = {
jiraIssueWatches: { items: [], loaded: false, loading: false },
};
type ImmerSet = Parameters<StateCreator<JiraSlice, [["zustand/immer", never]], [], JiraSlice>>[0];
export const createJiraSlice: StateCreator<JiraSlice, [["zustand/immer", never]], [], JiraSlice> = (
set: ImmerSet,
) => ({
...defaultJiraState,
setJiraIssueWatches: (watches) =>
set((draft) => {
draft.jiraIssueWatches.items = watches;
draft.jiraIssueWatches.loaded = true;
}),
setJiraIssueWatchesLoading: (loading) =>
set((draft) => {
draft.jiraIssueWatches.loading = loading;
}),
addJiraIssueWatch: (watch) =>
set((draft) => {
draft.jiraIssueWatches.items.push(watch);
}),
updateJiraIssueWatch: (watch) =>
set((draft) => {
const idx = draft.jiraIssueWatches.items.findIndex((w) => w.id === watch.id);
if (idx >= 0) {
draft.jiraIssueWatches.items[idx] = watch;
}
}),
removeJiraIssueWatch: (id) =>
set((draft) => {
draft.jiraIssueWatches.items = draft.jiraIssueWatches.items.filter((w) => w.id !== id);
}),
resetJiraIssueWatches: () =>
set((draft) => {
draft.jiraIssueWatches.items = [];
draft.jiraIssueWatches.loaded = false;
}),
});
+27
View File
@@ -0,0 +1,27 @@
import type { JiraIssueWatch } from "@/lib/types/jira";
export type JiraIssueWatchesState = {
items: JiraIssueWatch[];
loaded: boolean;
loading: boolean;
};
export type JiraSliceState = {
jiraIssueWatches: JiraIssueWatchesState;
};
export type JiraSliceActions = {
setJiraIssueWatches: (watches: JiraIssueWatch[]) => void;
setJiraIssueWatchesLoading: (loading: boolean) => void;
addJiraIssueWatch: (watch: JiraIssueWatch) => void;
updateJiraIssueWatch: (watch: JiraIssueWatch) => void;
removeJiraIssueWatch: (id: string) => void;
/**
* Clears items AND `loaded` so the next fetch effect runs again. Distinct
* from `setJiraIssueWatches([])`, which marks the empty list as loaded and
* would prevent a refetch on workspace switch.
*/
resetJiraIssueWatches: () => void;
};
export type JiraSlice = JiraSliceState & JiraSliceActions;
+21
View File
@@ -28,6 +28,7 @@ import {
createSessionRuntimeSlice,
createUISlice,
createGitHubSlice,
createJiraSlice,
defaultKanbanState,
defaultWorkspaceState,
defaultSettingsState,
@@ -35,6 +36,7 @@ import {
defaultSessionRuntimeState,
defaultUIState,
defaultGitHubState,
defaultJiraState,
type WorkspaceState,
type WorkflowsState,
type ExecutorsState,
@@ -133,7 +135,12 @@ export type {
PRWatchesState,
ReviewWatchesState,
IssueWatchesState,
JiraSlice,
JiraSliceState,
JiraSliceActions,
JiraIssueWatchesState,
} from "./slices";
import type { JiraIssueWatch } from "@/lib/types/jira";
// Combined AppState type
export type AppState = {
@@ -203,6 +210,9 @@ export type AppState = {
issueWatches: (typeof defaultGitHubState)["issueWatches"];
actionPresets: (typeof defaultGitHubState)["actionPresets"];
// JIRA slice
jiraIssueWatches: (typeof defaultJiraState)["jiraIssueWatches"];
// UI slice
previewPanel: (typeof defaultUIState)["previewPanel"];
rightPanel: (typeof defaultUIState)["rightPanel"];
@@ -243,6 +253,14 @@ export type AppState = {
setActionPresets: (workspaceId: string, presets: GitHubActionPresets) => void;
setActionPresetsLoading: (workspaceId: string, loading: boolean) => void;
// JIRA actions
setJiraIssueWatches: (watches: JiraIssueWatch[]) => void;
setJiraIssueWatchesLoading: (loading: boolean) => void;
addJiraIssueWatch: (watch: JiraIssueWatch) => void;
updateJiraIssueWatch: (watch: JiraIssueWatch) => void;
removeJiraIssueWatch: (id: string) => void;
resetJiraIssueWatches: () => void;
// Actions from all slices
hydrate: (state: Partial<AppState>, options?: HydrationOptions) => void;
setActiveWorkspace: (workspaceId: string | null) => void;
@@ -479,6 +497,8 @@ export function createAppStore(initialState?: Partial<AppState>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...createGitHubSlice(set as any, get as any, api as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...createJiraSlice(set as any, get as any, api as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...createUISlice(set as any, get as any, api as any),
// Override state with merged initial state
kanban: merged.kanban,
@@ -530,6 +550,7 @@ export function createAppStore(initialState?: Partial<AppState>) {
reviewWatches: merged.reviewWatches,
issueWatches: merged.issueWatches,
actionPresets: merged.actionPresets,
jiraIssueWatches: merged.jiraIssueWatches,
previewPanel: merged.previewPanel,
rightPanel: merged.rightPanel,
diffs: merged.diffs,
+46
View File
@@ -79,3 +79,49 @@ export interface JiraSearchResult {
isLast: boolean;
nextPageToken?: string;
}
/**
* A workspace-scoped JQL poller. The backend re-evaluates the JQL on
* `pollIntervalSeconds` cadence and creates a Kandev task in the configured
* workflow step for each newly-matching ticket.
*/
export interface JiraIssueWatch {
id: string;
workspaceId: string;
workflowId: string;
workflowStepId: string;
jql: string;
agentProfileId: string;
executorProfileId: string;
prompt: string;
enabled: boolean;
pollIntervalSeconds: number;
/** Last poll timestamp, or null when the watch has never run. */
lastPolledAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateJiraIssueWatchInput {
workspaceId: string;
workflowId: string;
workflowStepId: string;
jql: string;
agentProfileId?: string;
executorProfileId?: string;
prompt?: string;
pollIntervalSeconds?: number;
enabled?: boolean;
}
/** Patch shape: every field is optional so the UI can change one knob at a time. */
export interface UpdateJiraIssueWatchInput {
workflowId?: string;
workflowStepId?: string;
jql?: string;
agentProfileId?: string;
executorProfileId?: string;
prompt?: string;
enabled?: boolean;
pollIntervalSeconds?: number;
}