Files
kandev/apps/backend/internal/watchreset/reset.go
T
2026-06-15 18:54:16 +01:00

133 lines
4.6 KiB
Go

// Package watchreset orchestrates "reset" for integration watches
// (GitHub issue / GitHub review / Jira / Linear / Sentry). A reset is a
// destructive operation that:
//
// 1. Deletes every Kandev task previously created by the watch (cascade,
// including archived tasks — "wipe the slate").
// 2. Wipes the per-watch dedup rows so the next poll re-considers every
// currently-matching external item.
// 3. Clears the watch's last_polled_at so it polls immediately.
//
// Each integration implements the small Resetter interface against its own
// store, and calls Run with a *task.HandoffService for the cascade delete.
package watchreset
import (
"context"
"fmt"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
taskservice "github.com/kandev/kandev/internal/task/service"
)
// Resetter is the per-watch surface watchreset needs. Implementations
// close over the watch ID and the integration's store. Methods are called
// in a fixed order by Run: ListTaskIDs first, then Clear after the cascade
// delete loop completes.
type Resetter interface {
// ListTaskIDs returns every task ID previously created by the watch,
// including rows whose task_id is empty (those are skipped by Run but
// the dedup row will still be wiped by Clear).
ListTaskIDs(ctx context.Context) ([]string, error)
// Clear wipes all dedup rows for the watch and nulls its
// last_polled_at column. Implementations should do both in a single
// transaction so a partial reset cannot leave the watch in a state
// where it skips items it just decided to forget about.
Clear(ctx context.Context) error
}
// TaskDeleter is the cascade-delete entry point. Satisfied by
// *task.HandoffService.DeleteTaskTree.
type TaskDeleter interface {
DeleteTaskTree(ctx context.Context, rootID string, cascade bool) (*taskservice.CascadeOutcome, error)
}
// Result reports what Run did. TasksDeleted counts task trees that were
// successfully cascade-deleted; tasks that failed to delete are logged but
// don't fail the reset (the dedup table is still wiped so the watch
// re-imports them on the next poll).
type Result struct {
TasksDeleted int
}
// Run executes a watch reset. Deletes are best-effort: a delete failure
// for one task is logged and skipped so the dedup wipe still runs — a
// reset that fails halfway would leave the watch unable to re-import the
// items it just lost references to. Returns the final Result regardless;
// the only fatal error is the dedup/clear step itself.
//
// Once started, the reset runs to completion even if the caller's context
// is cancelled (e.g. the HTTP client disconnects mid-reset). A half-applied
// reset is worse than a slow one: the user already confirmed the
// destructive action, and the dedup wipe is the step that lets them retry
// safely. The cancel signal is preserved for deadlines that arrive
// before Run is invoked, but not for ones that fire after.
func Run(ctx context.Context, r Resetter, td TaskDeleter, log *logger.Logger) (Result, error) {
if r == nil {
return Result{}, fmt.Errorf("watchreset: nil Resetter")
}
if td == nil {
return Result{}, fmt.Errorf("watchreset: nil TaskDeleter")
}
// Respect a caller deadline that's already expired before we detach.
// WithoutCancel intentionally drops every signal, so swallowing an
// already-cancelled context here would silently run an unwanted reset.
if err := ctx.Err(); err != nil {
return Result{}, err
}
ctx = context.WithoutCancel(ctx)
ids, err := r.ListTaskIDs(ctx)
if err != nil {
return Result{}, fmt.Errorf("list watch task ids: %w", err)
}
deleted := 0
for _, id := range ids {
if id == "" {
continue
}
if _, err := td.DeleteTaskTree(ctx, id, true); err != nil {
if log != nil {
log.Warn("watchreset: delete task failed",
zap.String("task_id", id), zap.Error(err))
}
continue
}
deleted++
}
if err := r.Clear(ctx); err != nil {
return Result{TasksDeleted: deleted}, fmt.Errorf("clear watch state: %w", err)
}
if log != nil {
log.Info("watch.reset",
zap.Int("tasks_deleted", deleted),
zap.Int("tasks_considered", len(ids)))
}
return Result{TasksDeleted: deleted}, nil
}
// Preview returns the number of tasks that would be deleted by Run. It
// counts non-empty task IDs only — dedup rows whose task creation never
// completed don't have a Kandev task to delete.
func Preview(ctx context.Context, r Resetter) (int, error) {
if r == nil {
return 0, fmt.Errorf("watchreset: nil Resetter")
}
ids, err := r.ListTaskIDs(ctx)
if err != nil {
return 0, fmt.Errorf("list watch task ids: %w", err)
}
n := 0
for _, id := range ids {
if id != "" {
n++
}
}
return n, nil
}