fa4a48d5e7
* chore: remove orphan executor tracing file and deprecated ResolveScript wrappers - Delete internal/agentctl/tracing/executor.go (7 TraceExecutor* funcs) - Delete internal/orchestrator/executor/script_template.go (deprecated wrapper) - Delete scriptengine.ResolveScript convenience function Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove orphan setter methods across healthpoll, jira, linear, slack, updates - Delete Poller.SetInterval from integrations/healthpoll (no callers) - Delete Poller.SetIssueTickHook from jira and linear (no callers) - Delete Trigger.SetInterval and Trigger.SetMatchHook from slack (no callers) - Delete Service.SetPollInterval from system/updates (no callers) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove orphan wrapper functions and accessor method - Delete updates.FetchLatestRelease shim (wraps FetchLatestReleaseFrom, no callers) - Delete usage.NewClaudeUsageClient and NewCodexUsageClient shims (no callers) - Delete database.Service.Path() accessor (no callers) - Update ErrGitHubRateLimited godoc to reference FetchLatestReleaseFrom Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove unwired handleListExecutors MCP handler The sibling profile handlers (list/create/update/delete) are registered via RegisterFunc in handlers.go. handleListExecutors never was — dead flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove stale hook-field comments after setter deletion SetIssueTickHook (jira, linear) and SetMatchHook (slack) were removed in the deadcode cleanup. Their struct fields still claimed "tests use this" — no test or caller can set these fields now. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove orphan hook fields and fire methods after setter deletion issueTickHook (jira/linear) and matchHook (slack) are unexported and never set after their public setters were removed; fireIssueTickHook / fireHook were permanent no-ops. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
166 lines
4.2 KiB
Go
166 lines
4.2 KiB
Go
package linear
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/integrations/healthpoll"
|
|
)
|
|
|
|
// defaultIssuePollTickInterval is how often the issue-watch loop wakes up to
|
|
// consider every enabled watcher. The actual GraphQL search 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.
|
|
const defaultIssuePollTickInterval = 60 * time.Second
|
|
|
|
// Poller drives two background loops sharing a single Service:
|
|
// - auth health: probes stored credentials so the UI can show connect status.
|
|
// Delegated to internal/integrations/healthpoll for the loop semantics.
|
|
// - issue watches: runs each enabled watcher's filter and emits
|
|
// NewLinearIssueEvent for every matching issue the orchestrator hasn't yet
|
|
// seen.
|
|
//
|
|
// Both loops are cancelled together via Stop.
|
|
type Poller struct {
|
|
service *Service
|
|
logger *logger.Logger
|
|
auth *healthpoll.Poller
|
|
issueInterval time.Duration
|
|
|
|
mu sync.Mutex
|
|
cancelIssueLoop context.CancelFunc
|
|
wg sync.WaitGroup
|
|
started bool
|
|
}
|
|
|
|
// NewPoller returns a poller using the default cadences. Returns nil when svc
|
|
// is nil so the caller's nil-check still works after the indirection.
|
|
func NewPoller(svc *Service, log *logger.Logger) *Poller {
|
|
if svc == nil {
|
|
return nil
|
|
}
|
|
return &Poller{
|
|
service: svc,
|
|
logger: log,
|
|
auth: healthpoll.New("linear", svcProber{svc}, log),
|
|
issueInterval: defaultIssuePollTickInterval,
|
|
}
|
|
}
|
|
|
|
// Start launches both background loops. Calling Start more than once without
|
|
// Stop is a no-op. A nil receiver is also a no-op.
|
|
func (p *Poller) Start(ctx context.Context) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.started || p.service == nil {
|
|
return
|
|
}
|
|
p.started = true
|
|
issueCtx, cancel := context.WithCancel(ctx)
|
|
p.cancelIssueLoop = cancel
|
|
p.auth.Start(ctx)
|
|
p.wg.Add(1)
|
|
go p.issueWatchLoop(issueCtx)
|
|
}
|
|
|
|
// Stop cancels both loops and waits for them to drain.
|
|
func (p *Poller) Stop() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
if !p.started {
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
cancel := p.cancelIssueLoop
|
|
p.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
p.auth.Stop()
|
|
p.wg.Wait()
|
|
p.mu.Lock()
|
|
p.started = false
|
|
p.mu.Unlock()
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Poller) checkIssueWatches(ctx context.Context) {
|
|
watches, err := p.service.Store().ListEnabledIssueWatches(ctx)
|
|
if err != nil {
|
|
p.logger.Warn("linear 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
|
|
}
|
|
newIssues, err := p.service.CheckIssueWatch(ctx, w)
|
|
if err != nil {
|
|
p.logger.Debug("linear poller: check issue watch failed",
|
|
zap.String("watch_id", w.ID), zap.Error(err))
|
|
continue
|
|
}
|
|
for _, issue := range newIssues {
|
|
p.logger.Info("new linear issue found for watch",
|
|
zap.String("watch_id", w.ID),
|
|
zap.String("identifier", issue.Identifier),
|
|
zap.String("title", issue.Title))
|
|
p.service.publishNewLinearIssueEvent(ctx, w, issue)
|
|
}
|
|
}
|
|
}
|
|
|
|
// isIssueWatchDue reports whether enough time has passed since the watch was
|
|
// last polled to re-run its search.
|
|
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
|
|
}
|
|
|
|
// svcProber adapts *Service to healthpoll.Prober without leaking the shared
|
|
// interface into Service's public API.
|
|
type svcProber struct{ svc *Service }
|
|
|
|
func (s svcProber) HasConfig(ctx context.Context) (bool, error) {
|
|
return s.svc.Store().HasConfig(ctx)
|
|
}
|
|
|
|
func (s svcProber) RecordAuthHealth(ctx context.Context) {
|
|
s.svc.RecordAuthHealth(ctx)
|
|
}
|