399 lines
13 KiB
Go
399 lines
13 KiB
Go
package linear
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/watchreset"
|
|
)
|
|
|
|
// 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("linear: issue watch not found")
|
|
|
|
// SetEventBus wires the bus used to publish NewLinearIssueEvent. Optional: if
|
|
// unset the poller still runs but observed issues do not become Kandev tasks.
|
|
func (s *Service) SetEventBus(eb bus.EventBus) {
|
|
s.mu.Lock()
|
|
s.eventBus = eb
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// 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,
|
|
Filter: normalizeFilter(req.Filter),
|
|
AgentProfileID: req.AgentProfileID,
|
|
ExecutorProfileID: req.ExecutorProfileID,
|
|
Prompt: req.Prompt,
|
|
PollIntervalSeconds: req.PollIntervalSeconds,
|
|
MaxInflightTasks: req.MaxInflightTasks,
|
|
Enabled: true,
|
|
}
|
|
if req.Enabled != nil {
|
|
w.Enabled = *req.Enabled
|
|
}
|
|
if err := s.store.CreateIssueWatch(ctx, w); err != nil {
|
|
return nil, err
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
// ListIssueWatches returns the watches configured for a workspace.
|
|
func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) {
|
|
return s.store.ListIssueWatches(ctx, workspaceID)
|
|
}
|
|
|
|
// ListAllIssueWatches returns every watch across all workspaces.
|
|
func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) {
|
|
return s.store.ListAllIssueWatches(ctx)
|
|
}
|
|
|
|
// GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound.
|
|
func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) {
|
|
w, err := s.store.GetIssueWatch(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if w == nil {
|
|
return nil, ErrIssueWatchNotFound
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
// UpdateIssueWatch applies a partial update by patching only the fields the
|
|
// caller explicitly set, then persists the result.
|
|
func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) (*IssueWatch, error) {
|
|
w, err := s.GetIssueWatch(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyIssueWatchPatch(w, req)
|
|
if filterIsEmpty(w.Filter) {
|
|
return nil, fmt.Errorf("%w: filter must specify at least one of query, teamKey, stateIds, assigned, priorities, labelIds, creatorId, or estimate range", ErrInvalidConfig)
|
|
}
|
|
if err := validateFilterBounds(w.Filter); err != nil {
|
|
return nil, err
|
|
}
|
|
if w.WorkflowID == "" || w.WorkflowStepID == "" {
|
|
return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig)
|
|
}
|
|
if err := validatePollInterval(w.PollIntervalSeconds); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateMaxInflightTasks(w.MaxInflightTasks); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.store.UpdateIssueWatch(ctx, w); err != nil {
|
|
return nil, err
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
// DeleteIssueWatch removes the watch and its dedup rows. Idempotent.
|
|
func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error {
|
|
return s.store.DeleteIssueWatch(ctx, id)
|
|
}
|
|
|
|
// linearIssueWatchResetter is the watchreset.Resetter adapter for a single
|
|
// Linear issue watch. Closes over the store and watch ID so the shared
|
|
// watchreset.Run helper stays integration-agnostic.
|
|
type linearIssueWatchResetter struct {
|
|
store *Store
|
|
watchID string
|
|
}
|
|
|
|
func (r *linearIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
|
|
return r.store.ListIssueWatchTaskIDs(ctx, r.watchID)
|
|
}
|
|
|
|
func (r *linearIssueWatchResetter) Clear(ctx context.Context) error {
|
|
return r.store.ResetIssueWatchState(ctx, r.watchID)
|
|
}
|
|
|
|
// PreviewResetIssueWatch returns how many tasks ResetIssueWatch would
|
|
// cascade-delete. Used by the frontend to populate the confirmation dialog.
|
|
func (s *Service) PreviewResetIssueWatch(ctx context.Context, watchID string) (int, error) {
|
|
return watchreset.Preview(ctx, &linearIssueWatchResetter{store: s.store, watchID: watchID})
|
|
}
|
|
|
|
// ResetIssueWatch is destructive: cascade-deletes every task previously
|
|
// created by the watch (including archived), wipes the per-watch dedup
|
|
// rows, and nulls last_polled_at so the next poll re-imports every
|
|
// currently-matching issue. Returns the count of tasks deleted.
|
|
func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, error) {
|
|
s.mu.Lock()
|
|
td := s.taskDeleter
|
|
s.mu.Unlock()
|
|
if td == nil {
|
|
return 0, errors.New("linear: task deleter not wired; reset unavailable")
|
|
}
|
|
res, err := watchreset.Run(ctx,
|
|
&linearIssueWatchResetter{store: s.store, watchID: watchID},
|
|
td, s.log)
|
|
return res.TasksDeleted, err
|
|
}
|
|
|
|
// CheckIssueWatch runs the watch's filter once and returns the issues 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".
|
|
//
|
|
// Concurrency note: callers must tolerate being handed an issue that gets
|
|
// stolen by a concurrent reserver. We query the seen-set and return unseen
|
|
// identifiers, but we do NOT insert the dedup row here — that happens in the
|
|
// orchestrator's ReserveIssueWatchTask via INSERT OR IGNORE. If the manual
|
|
// /trigger endpoint and the poller tick fire for the same watch in quick
|
|
// succession, both calls can see the same identifier as unseen before either
|
|
// has reserved it. The duplicate publish is harmless (the second reserver
|
|
// loses the race and bails) but the goroutine work is wasted. Same pattern as
|
|
// the JIRA watcher.
|
|
func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*LinearIssue, error) {
|
|
defer s.stampWatchLastPolled(w.ID)
|
|
client, err := s.clientFor(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := client.SearchIssues(ctx, w.Filter, "", issueWatchSearchPageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seen, err := s.store.ListSeenIssueIdentifiers(ctx, w.ID)
|
|
if err != nil {
|
|
s.log.Warn("linear: dedup set fetch failed",
|
|
zap.String("watch_id", w.ID), zap.Error(err))
|
|
seen = nil
|
|
}
|
|
out := make([]*LinearIssue, 0, len(res.Issues))
|
|
for i := range res.Issues {
|
|
issue := res.Issues[i]
|
|
if _, ok := seen[issue.Identifier]; ok {
|
|
continue
|
|
}
|
|
out = append(out, &issue)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// stampWatchLastPolled writes the current timestamp using a fresh background
|
|
// context with a short write deadline, so a cancelled caller ctx (e.g. shutdown)
|
|
// doesn't drop the liveness record.
|
|
func (s *Service) stampWatchLastPolled(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("linear: update last_polled_at failed",
|
|
zap.String("watch_id", watchID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// publishNewLinearIssueEvent emits the orchestrator-facing event for one
|
|
// freshly-observed issue. No-op when the event bus is not wired (tests, early
|
|
// boot).
|
|
func (s *Service) publishNewLinearIssueEvent(ctx context.Context, w *IssueWatch, issue *LinearIssue) {
|
|
s.mu.Lock()
|
|
eb := s.eventBus
|
|
s.mu.Unlock()
|
|
if eb == nil {
|
|
return
|
|
}
|
|
evt := bus.NewEvent(events.LinearNewIssue, "linear", &NewLinearIssueEvent{
|
|
IssueWatchID: w.ID,
|
|
WorkspaceID: w.WorkspaceID,
|
|
WorkflowID: w.WorkflowID,
|
|
WorkflowStepID: w.WorkflowStepID,
|
|
AgentProfileID: w.AgentProfileID,
|
|
ExecutorProfileID: w.ExecutorProfileID,
|
|
Prompt: w.Prompt,
|
|
MaxInflightTasks: w.MaxInflightTasks,
|
|
Issue: issue,
|
|
})
|
|
if err := eb.Publish(ctx, events.LinearNewIssue, evt); err != nil {
|
|
s.log.Debug("linear: publish new issue event failed",
|
|
zap.String("watch_id", w.ID), zap.String("identifier", issue.Identifier), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// issueWatchSearchPageSize caps how many issues a single CheckIssueWatch call
|
|
// pulls from Linear. Mirrors the Jira watcher's limit so per-tick cost stays
|
|
// bounded for very broad filters.
|
|
const issueWatchSearchPageSize = 50
|
|
|
|
// MinIssueWatchPollInterval / MaxIssueWatchPollInterval bound the per-watch
|
|
// search re-run cadence.
|
|
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 filterIsEmpty(normalizeFilter(req.Filter)) {
|
|
return fmt.Errorf("%w: filter must specify at least one of query, teamKey, stateIds, assigned, priorities, labelIds, creatorId, or estimate range", ErrInvalidConfig)
|
|
}
|
|
if err := validateFilterBounds(req.Filter); err != nil {
|
|
return err
|
|
}
|
|
if req.PollIntervalSeconds != 0 {
|
|
if err := validatePollInterval(req.PollIntervalSeconds); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := validateMaxInflightTasks(req.MaxInflightTasks); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateMaxInflightTasks rejects non-positive caps. A nil pointer is
|
|
// "uncapped" and explicitly allowed.
|
|
func validateMaxInflightTasks(v *int) error {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if *v <= 0 {
|
|
return fmt.Errorf("%w: maxInflightTasks must be a positive integer", ErrInvalidConfig)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateFilterBounds rejects out-of-range values for fields where the wire
|
|
// type permits invalid values (e.g. Priority being an unconstrained int).
|
|
// Empty / unset fields pass without check.
|
|
func validateFilterBounds(f SearchFilter) error {
|
|
for _, p := range f.Priorities {
|
|
if p < 0 || p > 4 {
|
|
return fmt.Errorf("%w: priority must be between 0 and 4", ErrInvalidConfig)
|
|
}
|
|
}
|
|
if err := validateEstimateBound(f.EstimateMin, "estimateMin"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateEstimateBound(f.EstimateMax, "estimateMax"); err != nil {
|
|
return err
|
|
}
|
|
if f.EstimateMin != nil && f.EstimateMax != nil && *f.EstimateMin > *f.EstimateMax {
|
|
return fmt.Errorf("%w: estimateMin cannot be greater than estimateMax", ErrInvalidConfig)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateEstimateBound rejects NaN, ±Inf, and negative values. json.Marshal
|
|
// fails on NaN/Inf, so without this check a malformed config would surface as a
|
|
// 500 at watch-time instead of a clean validation error at create-time.
|
|
func validateEstimateBound(v *float64, name string) error {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if math.IsNaN(*v) || math.IsInf(*v, 0) || *v < 0 {
|
|
return fmt.Errorf("%w: %s must be a non-negative number", ErrInvalidConfig, name)
|
|
}
|
|
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
|
|
}
|
|
|
|
// normalizeFilter trims string fields and drops empty stateIds entries, so a
|
|
// filter that looks empty after normalization fails the at-least-one check
|
|
// instead of slipping through with whitespace.
|
|
func normalizeFilter(f SearchFilter) SearchFilter {
|
|
out := SearchFilter{
|
|
Query: strings.TrimSpace(f.Query),
|
|
TeamKey: strings.TrimSpace(f.TeamKey),
|
|
Assigned: strings.TrimSpace(f.Assigned),
|
|
CreatorID: strings.TrimSpace(f.CreatorID),
|
|
EstimateMin: f.EstimateMin,
|
|
EstimateMax: f.EstimateMax,
|
|
}
|
|
for _, id := range f.StateIDs {
|
|
id = strings.TrimSpace(id)
|
|
if id != "" {
|
|
out.StateIDs = append(out.StateIDs, id)
|
|
}
|
|
}
|
|
for _, id := range f.LabelIDs {
|
|
id = strings.TrimSpace(id)
|
|
if id != "" {
|
|
out.LabelIDs = append(out.LabelIDs, id)
|
|
}
|
|
}
|
|
seenPriority := map[int]bool{}
|
|
for _, p := range f.Priorities {
|
|
if seenPriority[p] {
|
|
continue
|
|
}
|
|
seenPriority[p] = true
|
|
out.Priorities = append(out.Priorities, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func filterIsEmpty(f SearchFilter) bool {
|
|
return f.Query == "" &&
|
|
f.TeamKey == "" &&
|
|
f.Assigned == "" &&
|
|
len(f.StateIDs) == 0 &&
|
|
len(f.Priorities) == 0 &&
|
|
len(f.LabelIDs) == 0 &&
|
|
f.CreatorID == "" &&
|
|
f.EstimateMin == nil &&
|
|
f.EstimateMax == 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.Filter != nil {
|
|
w.Filter = normalizeFilter(*req.Filter)
|
|
}
|
|
if req.AgentProfileID != nil {
|
|
w.AgentProfileID = *req.AgentProfileID
|
|
}
|
|
if req.ExecutorProfileID != nil {
|
|
w.ExecutorProfileID = *req.ExecutorProfileID
|
|
}
|
|
if req.Prompt != nil {
|
|
w.Prompt = *req.Prompt
|
|
}
|
|
if req.Enabled != nil {
|
|
w.Enabled = *req.Enabled
|
|
}
|
|
if req.PollIntervalSeconds != nil {
|
|
w.PollIntervalSeconds = *req.PollIntervalSeconds
|
|
}
|
|
// MaxInflightTasks is tri-state (optional.Int): only apply it when the
|
|
// PATCH actually carried the key, so a partial update that omits it
|
|
// leaves the cap intact. Present+null clears the cap; present+int sets it.
|
|
if req.MaxInflightTasks.Present {
|
|
w.MaxInflightTasks = req.MaxInflightTasks.Value
|
|
}
|
|
}
|