133 lines
4.6 KiB
Go
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
|
|
}
|