feat: add reset action for integration watches (#1381)
This commit is contained in:
@@ -504,6 +504,22 @@ func registerRoutes(p routeParams) {
|
||||
// runCanceller but its container leaks because the cascade bypasses
|
||||
// Service.ArchiveTask's runAsyncTaskCleanup branch.
|
||||
handoffSvc.SetTaskResourceCleaner(p.taskSvc)
|
||||
// Watch reset (Reset button on integration settings) cascade-deletes
|
||||
// every task a watch previously created. The integrations re-use the
|
||||
// shared HandoffService so the reset path goes through the same
|
||||
// cleanup machinery as the regular delete-task surface.
|
||||
if p.services.GitHub != nil {
|
||||
p.services.GitHub.SetCascadeTaskDeleter(handoffSvc)
|
||||
}
|
||||
if p.services.Jira != nil {
|
||||
p.services.Jira.SetTaskDeleter(handoffSvc)
|
||||
}
|
||||
if p.services.Linear != nil {
|
||||
p.services.Linear.SetTaskDeleter(handoffSvc)
|
||||
}
|
||||
if p.services.Sentry != nil {
|
||||
p.services.Sentry.SetTaskDeleter(handoffSvc)
|
||||
}
|
||||
p.orchestratorSvc.SetWorkspaceMaterializer(handoffSvc)
|
||||
// Phase 8 prompt enrichment — wire the office scheduler's
|
||||
// TaskContextProvider so every run prompt rendered by the active
|
||||
|
||||
@@ -51,6 +51,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.PUT("/watches/review/:id", c.httpUpdateReviewWatch)
|
||||
api.DELETE("/watches/review/:id", c.httpDeleteReviewWatch)
|
||||
api.POST("/watches/review/:id/trigger", c.httpTriggerReviewWatch)
|
||||
api.GET("/watches/review/:id/reset/preview", c.httpPreviewResetReviewWatch)
|
||||
api.POST("/watches/review/:id/reset", c.httpResetReviewWatch)
|
||||
api.POST("/watches/review/trigger-all", c.httpTriggerAllReviewChecks)
|
||||
|
||||
api.GET("/watches/issue", c.httpListIssueWatches)
|
||||
@@ -58,6 +60,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.PUT("/watches/issue/:id", c.httpUpdateIssueWatch)
|
||||
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
|
||||
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch)
|
||||
api.GET("/watches/issue/:id/reset/preview", c.httpPreviewResetIssueWatch)
|
||||
api.POST("/watches/issue/:id/reset", c.httpResetIssueWatch)
|
||||
api.POST("/watches/issue/trigger-all", c.httpTriggerAllIssueChecks)
|
||||
|
||||
api.POST("/cleanup/review-tasks", c.httpCleanupReviewTasks)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// httpPreviewResetReviewWatch returns the count of tasks a reset on the
|
||||
// review watch would cascade-delete. Used by the confirmation dialog so the
|
||||
// user sees "delete N task(s)" before they commit.
|
||||
func (c *Controller) httpPreviewResetReviewWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.requireReviewWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.PreviewResetReviewWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeResetError(ctx, "preview reset review watch", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"taskCount": n})
|
||||
}
|
||||
|
||||
// httpResetReviewWatch executes the destructive reset: cascade-deletes
|
||||
// every task previously created by the review watch (including archived),
|
||||
// wipes its dedup table, and nulls last_polled_at so the next poll
|
||||
// re-imports every currently-matching PR.
|
||||
func (c *Controller) httpResetReviewWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.requireReviewWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.ResetReviewWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeResetError(ctx, "reset review watch", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"tasksDeleted": n})
|
||||
}
|
||||
|
||||
// httpPreviewResetIssueWatch returns the count of tasks a reset on the
|
||||
// issue watch would cascade-delete.
|
||||
func (c *Controller) httpPreviewResetIssueWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.requireIssueWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.PreviewResetIssueWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeResetError(ctx, "preview reset issue watch", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"taskCount": n})
|
||||
}
|
||||
|
||||
// httpResetIssueWatch executes the destructive reset for an issue watch.
|
||||
func (c *Controller) httpResetIssueWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.requireIssueWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.ResetIssueWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeResetError(ctx, "reset issue watch", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"tasksDeleted": n})
|
||||
}
|
||||
|
||||
// requireReviewWatchInWorkspace guards the reset endpoints against IDOR:
|
||||
// the caller must supply `?workspace_id=...` matching the watch's stored
|
||||
// workspace. Mismatch and not-found both return 404 so a probing client
|
||||
// can't tell whether a watch ID exists in another workspace.
|
||||
func (c *Controller) requireReviewWatchInWorkspace(ctx *gin.Context, id string) bool {
|
||||
watch, err := c.service.GetReviewWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeResetError(ctx, "load review watch", err)
|
||||
return false
|
||||
}
|
||||
if watch == nil || ctx.Query("workspace_id") == "" || ctx.Query("workspace_id") != watch.WorkspaceID {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "review watch not found"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// requireIssueWatchInWorkspace mirrors requireReviewWatchInWorkspace for
|
||||
// the issue watch reset endpoints.
|
||||
func (c *Controller) requireIssueWatchInWorkspace(ctx *gin.Context, id string) bool {
|
||||
watch, err := c.service.GetIssueWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeResetError(ctx, "load issue watch", err)
|
||||
return false
|
||||
}
|
||||
if watch == nil || ctx.Query("workspace_id") == "" || ctx.Query("workspace_id") != watch.WorkspaceID {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "issue watch not found"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// writeResetError logs the raw error and returns a generic message to the
|
||||
// client so internal store/SQL details don't leak through the HTTP response.
|
||||
func (c *Controller) writeResetError(ctx *gin.Context, op string, err error) {
|
||||
c.logger.Error(op, zap.Error(err))
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": op + " failed"})
|
||||
}
|
||||
@@ -43,6 +43,13 @@ var ErrInvalidToken = errors.New("invalid token")
|
||||
// transient upstream blip.
|
||||
var ErrRepoNotResolvable = errors.New("github: repository not resolvable")
|
||||
|
||||
// errStoreUnavailable is returned from service methods when no Store is
|
||||
// wired (Provide can return a Service with store == nil when the SQLite
|
||||
// repos aren't configured). Returning a typed error instead of nil-
|
||||
// dereferencing keeps the watch reset handlers from panicking under that
|
||||
// degenerate config — the caller surfaces a 5xx, the process keeps running.
|
||||
var errStoreUnavailable = errors.New("github: store not configured")
|
||||
|
||||
// repoNotResolvableSubstrings matches the wire-format substrings GitHub's
|
||||
// GraphQL/REST APIs use for a deterministic "repository does not exist /
|
||||
// not accessible" outcome:
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/watchreset"
|
||||
)
|
||||
|
||||
// Auth method constants.
|
||||
@@ -70,15 +71,21 @@ type SecretManager interface {
|
||||
|
||||
// Service coordinates GitHub integration operations.
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
client Client
|
||||
authMethod string
|
||||
secrets SecretProvider
|
||||
secretManager SecretManager
|
||||
store *Store
|
||||
eventBus bus.EventBus
|
||||
logger *logger.Logger
|
||||
taskDeleter TaskDeleter
|
||||
mu sync.Mutex
|
||||
client Client
|
||||
authMethod string
|
||||
secrets SecretProvider
|
||||
secretManager SecretManager
|
||||
store *Store
|
||||
eventBus bus.EventBus
|
||||
logger *logger.Logger
|
||||
taskDeleter TaskDeleter
|
||||
// cascadeTaskDeleter is the cascade-delete entry point used by the
|
||||
// watch reset flow. It is distinct from taskDeleter (which only deletes
|
||||
// a single task by ID) because reset must walk the task tree and clean
|
||||
// up subtasks, runs, and worktrees too. Wired post-construction via
|
||||
// SetCascadeTaskDeleter to avoid an import cycle with the task service.
|
||||
cascadeTaskDeleter watchreset.TaskDeleter
|
||||
taskSessionChecker TaskSessionChecker
|
||||
syncGroup singleflight.Group
|
||||
taskEventSubs []bus.Subscription
|
||||
@@ -176,6 +183,16 @@ func (s *Service) newPATClient(token string) *PATClient {
|
||||
// SetTaskDeleter sets the task deletion dependency for cleanup operations.
|
||||
func (s *Service) SetTaskDeleter(d TaskDeleter) { s.taskDeleter = d }
|
||||
|
||||
// SetCascadeTaskDeleter wires the cascade-delete dependency used by the
|
||||
// watch reset flow (ResetIssueWatch / ResetReviewWatch). Optional — when
|
||||
// unset, reset returns an error so the missing wiring is surfaced instead
|
||||
// of silently no-op'ing.
|
||||
func (s *Service) SetCascadeTaskDeleter(d watchreset.TaskDeleter) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cascadeTaskDeleter = d
|
||||
}
|
||||
|
||||
// SetTaskSessionChecker sets the session checker for cleanup operations.
|
||||
func (s *Service) SetTaskSessionChecker(c TaskSessionChecker) { s.taskSessionChecker = c }
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/kandev/kandev/internal/watchreset"
|
||||
)
|
||||
|
||||
// --- Issue watch reset ---
|
||||
|
||||
// githubIssueWatchResetter is the watchreset.Resetter adapter for a single
|
||||
// GitHub issue watch. Closes over the store and watch ID so the shared
|
||||
// watchreset.Run helper stays integration-agnostic.
|
||||
type githubIssueWatchResetter struct {
|
||||
store *Store
|
||||
watchID string
|
||||
}
|
||||
|
||||
func (r *githubIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
|
||||
return r.store.ListIssueWatchTaskIDsByWatch(ctx, r.watchID)
|
||||
}
|
||||
|
||||
func (r *githubIssueWatchResetter) 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) {
|
||||
if s.store == nil {
|
||||
return 0, errStoreUnavailable
|
||||
}
|
||||
return watchreset.Preview(ctx, &githubIssueWatchResetter{store: s.store, watchID: watchID})
|
||||
}
|
||||
|
||||
// ResetIssueWatch is destructive: cascade-deletes every task previously
|
||||
// created by the issue 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) {
|
||||
if s.store == nil {
|
||||
return 0, errStoreUnavailable
|
||||
}
|
||||
s.mu.Lock()
|
||||
td := s.cascadeTaskDeleter
|
||||
s.mu.Unlock()
|
||||
if td == nil {
|
||||
return 0, errors.New("github: cascade task deleter not wired; reset unavailable")
|
||||
}
|
||||
res, err := watchreset.Run(ctx,
|
||||
&githubIssueWatchResetter{store: s.store, watchID: watchID},
|
||||
td, s.logger)
|
||||
return res.TasksDeleted, err
|
||||
}
|
||||
|
||||
// --- Review watch reset ---
|
||||
|
||||
// githubReviewWatchResetter is the watchreset.Resetter adapter for a single
|
||||
// GitHub review watch.
|
||||
type githubReviewWatchResetter struct {
|
||||
store *Store
|
||||
watchID string
|
||||
}
|
||||
|
||||
func (r *githubReviewWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
|
||||
return r.store.ListReviewPRTaskIDsByWatch(ctx, r.watchID)
|
||||
}
|
||||
|
||||
func (r *githubReviewWatchResetter) Clear(ctx context.Context) error {
|
||||
return r.store.ResetReviewWatchState(ctx, r.watchID)
|
||||
}
|
||||
|
||||
// PreviewResetReviewWatch returns how many tasks ResetReviewWatch would
|
||||
// cascade-delete. Used by the frontend to populate the confirmation dialog.
|
||||
func (s *Service) PreviewResetReviewWatch(ctx context.Context, watchID string) (int, error) {
|
||||
if s.store == nil {
|
||||
return 0, errStoreUnavailable
|
||||
}
|
||||
return watchreset.Preview(ctx, &githubReviewWatchResetter{store: s.store, watchID: watchID})
|
||||
}
|
||||
|
||||
// ResetReviewWatch is destructive: cascade-deletes every task previously
|
||||
// created by the review watch (including archived), wipes the per-watch
|
||||
// dedup rows, and nulls last_polled_at so the next poll re-imports every
|
||||
// currently-matching PR. Returns the count of tasks deleted.
|
||||
func (s *Service) ResetReviewWatch(ctx context.Context, watchID string) (int, error) {
|
||||
if s.store == nil {
|
||||
return 0, errStoreUnavailable
|
||||
}
|
||||
s.mu.Lock()
|
||||
td := s.cascadeTaskDeleter
|
||||
s.mu.Unlock()
|
||||
if td == nil {
|
||||
return 0, errors.New("github: cascade task deleter not wired; reset unavailable")
|
||||
}
|
||||
res, err := watchreset.Run(ctx,
|
||||
&githubReviewWatchResetter{store: s.store, watchID: watchID},
|
||||
td, s.logger)
|
||||
return res.TasksDeleted, err
|
||||
}
|
||||
@@ -1236,6 +1236,38 @@ func (s *Store) DeleteReviewPRTask(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ListReviewPRTaskIDsByWatch returns every task_id recorded against a
|
||||
// review watch, including empty-string reservations. Used by the watch
|
||||
// reset flow to enumerate the tasks to cascade-delete.
|
||||
func (s *Store) ListReviewPRTaskIDsByWatch(ctx context.Context, watchID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := s.ro.SelectContext(ctx, &ids,
|
||||
`SELECT task_id FROM github_review_pr_tasks WHERE review_watch_id = ?`, watchID)
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ResetReviewWatchState wipes a review watch's dedup rows and nulls its
|
||||
// last_polled_at in a single transaction. Used by the reset flow after
|
||||
// the cascade-delete loop so the next poll re-imports every currently
|
||||
// matching PR as if the watch were freshly created.
|
||||
func (s *Store) ResetReviewWatchState(ctx context.Context, watchID 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 github_review_pr_tasks WHERE review_watch_id = ?`, watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE github_review_watches SET last_polled_at = NULL, updated_at = ? WHERE id = ?`,
|
||||
time.Now().UTC(), watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- Stats queries ---
|
||||
|
||||
// prStatsQuery builds parameterised SELECT queries against the github_task_prs table.
|
||||
@@ -1595,6 +1627,38 @@ func (s *Store) DeleteIssueWatchTask(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ListIssueWatchTaskIDsByWatch returns every task_id recorded against an
|
||||
// issue watch, including empty-string reservations. Used by the watch
|
||||
// reset flow to enumerate the tasks to cascade-delete.
|
||||
func (s *Store) ListIssueWatchTaskIDsByWatch(ctx context.Context, watchID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := s.ro.SelectContext(ctx, &ids,
|
||||
`SELECT task_id FROM github_issue_watch_tasks WHERE issue_watch_id = ?`, watchID)
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ResetIssueWatchState wipes an issue watch's dedup rows and nulls its
|
||||
// last_polled_at in a single transaction. Used by the reset flow after
|
||||
// the cascade-delete loop so the next poll re-imports every currently
|
||||
// matching issue as if the watch were freshly created.
|
||||
func (s *Store) ResetIssueWatchState(ctx context.Context, watchID 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 github_issue_watch_tasks WHERE issue_watch_id = ?`, watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE github_issue_watches SET last_polled_at = NULL, updated_at = ? WHERE id = ?`,
|
||||
time.Now().UTC(), watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- Action preset operations ---
|
||||
|
||||
// GetActionPresets returns stored PR/Issue presets for a workspace. Returns
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStore_ReviewWatch_ListTaskIDsAndReset pins the contract used by the
|
||||
// review watch reset flow: every dedup row's task_id (including empty
|
||||
// reservations) is enumerable, and ResetReviewWatchState wipes those rows
|
||||
// atomically alongside last_polled_at.
|
||||
func TestStore_ReviewWatch_ListTaskIDsAndReset(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
rw := &ReviewWatch{
|
||||
WorkspaceID: "ws-1",
|
||||
WorkflowID: "wf-1",
|
||||
WorkflowStepID: "step-1",
|
||||
AgentProfileID: "agent-1",
|
||||
ExecutorProfileID: "exec-1",
|
||||
ReviewScope: "user_and_teams",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := store.CreateReviewWatch(ctx, rw); err != nil {
|
||||
t.Fatalf("create review watch: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rw.LastPolledAt = &now
|
||||
rw.UpdatedAt = now
|
||||
if err := store.UpdateReviewWatch(ctx, rw); err != nil {
|
||||
t.Fatalf("stamp last polled: %v", err)
|
||||
}
|
||||
|
||||
tasks := []*ReviewPRTask{
|
||||
{ReviewWatchID: rw.ID, RepoOwner: "acme", RepoName: "widget", PRNumber: 1, PRURL: "https://github.com/acme/widget/pull/1", TaskID: "task-a", CreatedAt: now},
|
||||
{ReviewWatchID: rw.ID, RepoOwner: "acme", RepoName: "widget", PRNumber: 2, PRURL: "https://github.com/acme/widget/pull/2", TaskID: "task-b", CreatedAt: now},
|
||||
{ReviewWatchID: rw.ID, RepoOwner: "acme", RepoName: "widget", PRNumber: 3, PRURL: "https://github.com/acme/widget/pull/3", TaskID: "", CreatedAt: now},
|
||||
}
|
||||
for _, tp := range tasks {
|
||||
if err := store.CreateReviewPRTask(ctx, tp); err != nil {
|
||||
t.Fatalf("create review pr task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ids, err := store.ListReviewPRTaskIDsByWatch(ctx, rw.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids: %v", err)
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("ListReviewPRTaskIDsByWatch returned %d rows, want 3 (including empty reservation)", len(ids))
|
||||
}
|
||||
nonEmpty := 0
|
||||
for _, id := range ids {
|
||||
if id != "" {
|
||||
nonEmpty++
|
||||
}
|
||||
}
|
||||
if nonEmpty != 2 {
|
||||
t.Errorf("expected 2 non-empty task IDs, got %d", nonEmpty)
|
||||
}
|
||||
|
||||
if err := store.ResetReviewWatchState(ctx, rw.ID); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
|
||||
idsAfter, err := store.ListReviewPRTaskIDsByWatch(ctx, rw.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids after reset: %v", err)
|
||||
}
|
||||
if len(idsAfter) != 0 {
|
||||
t.Errorf("expected 0 dedup rows after reset, got %d", len(idsAfter))
|
||||
}
|
||||
got, err := store.GetReviewWatch(ctx, rw.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get watch: %v", err)
|
||||
}
|
||||
if got.LastPolledAt != nil {
|
||||
t.Errorf("expected LastPolledAt to be nil after reset, got %v", got.LastPolledAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_IssueWatch_ListTaskIDsAndReset mirrors the review watch test for
|
||||
// the issue watch reset path.
|
||||
func TestStore_IssueWatch_ListTaskIDsAndReset(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
iw := &IssueWatch{
|
||||
WorkspaceID: "ws-1",
|
||||
WorkflowID: "wf-1",
|
||||
WorkflowStepID: "step-1",
|
||||
AgentProfileID: "agent-1",
|
||||
ExecutorProfileID: "exec-1",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := store.CreateIssueWatch(ctx, iw); err != nil {
|
||||
t.Fatalf("create issue watch: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
iw.LastPolledAt = &now
|
||||
iw.UpdatedAt = now
|
||||
if err := store.UpdateIssueWatch(ctx, iw); err != nil {
|
||||
t.Fatalf("stamp last polled: %v", err)
|
||||
}
|
||||
|
||||
// Three reservations, only two get task IDs assigned so the third
|
||||
// exercises the empty-ID inclusion the reset flow depends on.
|
||||
for _, n := range []int{1, 2, 3} {
|
||||
if _, err := store.ReserveIssueWatchTask(ctx, iw.ID, "acme", "widget", n, "https://github.com/acme/widget/issues/0"); err != nil {
|
||||
t.Fatalf("reserve %d: %v", n, err)
|
||||
}
|
||||
}
|
||||
if err := store.AssignIssueWatchTaskID(ctx, iw.ID, "acme", "widget", 1, "task-a"); err != nil {
|
||||
t.Fatalf("assign 1: %v", err)
|
||||
}
|
||||
if err := store.AssignIssueWatchTaskID(ctx, iw.ID, "acme", "widget", 2, "task-b"); err != nil {
|
||||
t.Fatalf("assign 2: %v", err)
|
||||
}
|
||||
|
||||
ids, err := store.ListIssueWatchTaskIDsByWatch(ctx, iw.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids: %v", err)
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("ListIssueWatchTaskIDsByWatch returned %d rows, want 3 (including empty reservation)", len(ids))
|
||||
}
|
||||
|
||||
if err := store.ResetIssueWatchState(ctx, iw.ID); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
|
||||
idsAfter, err := store.ListIssueWatchTaskIDsByWatch(ctx, iw.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids after reset: %v", err)
|
||||
}
|
||||
if len(idsAfter) != 0 {
|
||||
t.Errorf("expected 0 dedup rows after reset, got %d", len(idsAfter))
|
||||
}
|
||||
got, err := store.GetIssueWatch(ctx, iw.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get watch: %v", err)
|
||||
}
|
||||
if got.LastPolledAt != nil {
|
||||
t.Errorf("expected LastPolledAt to be nil after reset, got %v", got.LastPolledAt)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch)
|
||||
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
|
||||
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch)
|
||||
api.GET("/watches/issue/:id/reset/preview", c.httpPreviewResetIssueWatch)
|
||||
api.POST("/watches/issue/:id/reset", c.httpResetIssueWatch)
|
||||
}
|
||||
|
||||
// --- HTTP handlers ---
|
||||
@@ -242,6 +244,39 @@ func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{"newIssues": len(tickets)})
|
||||
}
|
||||
|
||||
// httpPreviewResetIssueWatch returns the count of tasks that a reset on the
|
||||
// watch would cascade-delete. Used by the confirmation dialog so the user
|
||||
// sees "delete N task(s)" before they commit.
|
||||
func (c *Controller) httpPreviewResetIssueWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.assertWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.PreviewResetIssueWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeIssueWatchError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"taskCount": n})
|
||||
}
|
||||
|
||||
// httpResetIssueWatch executes the destructive reset: cascade-deletes all
|
||||
// tasks previously created by the watch (including archived), wipes its
|
||||
// dedup table, and nulls last_polled_at so the next poll re-imports
|
||||
// everything currently matching the JQL.
|
||||
func (c *Controller) httpResetIssueWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.assertWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.ResetIssueWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeIssueWatchError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"tasksDeleted": n})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/watchreset"
|
||||
)
|
||||
|
||||
// SecretStore is the subset of the secrets store the service needs. Kept small
|
||||
@@ -35,12 +36,26 @@ type Service struct {
|
||||
client Client // singleton, cleared on config change.
|
||||
probeHook func()
|
||||
eventBus bus.EventBus
|
||||
// taskDeleter is the cascade-delete entry point used by the watch
|
||||
// reset flow. Wired post-construction via SetTaskDeleter to avoid
|
||||
// the import cycle that would result from passing the concrete
|
||||
// *task.HandoffService into NewService.
|
||||
taskDeleter watchreset.TaskDeleter
|
||||
// mockClient is non-nil only when Provide built the service with a MockClient
|
||||
// (KANDEV_MOCK_JIRA=true). Exposed via MockClient() so the e2e control routes
|
||||
// can drive the same instance the clientFn returns.
|
||||
mockClient *MockClient
|
||||
}
|
||||
|
||||
// SetTaskDeleter wires the cascade-delete dependency used by ResetIssueWatch.
|
||||
// Optional — when unset, reset returns an error so the missing wiring is
|
||||
// surfaced instead of silently no-op'ing.
|
||||
func (s *Service) SetTaskDeleter(td watchreset.TaskDeleter) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.taskDeleter = td
|
||||
}
|
||||
|
||||
// MockClient returns the shared mock client when the service was built in mock
|
||||
// mode, or nil for production builds.
|
||||
func (s *Service) MockClient() *MockClient {
|
||||
@@ -581,6 +596,45 @@ func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error {
|
||||
return s.store.DeleteIssueWatch(ctx, id)
|
||||
}
|
||||
|
||||
// jiraIssueWatchResetter is the watchreset.Resetter adapter for a single
|
||||
// Jira issue watch. Closes over the store and watch ID so the shared
|
||||
// watchreset.Run helper stays integration-agnostic.
|
||||
type jiraIssueWatchResetter struct {
|
||||
store *Store
|
||||
watchID string
|
||||
}
|
||||
|
||||
func (r *jiraIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
|
||||
return r.store.ListIssueWatchTaskIDs(ctx, r.watchID)
|
||||
}
|
||||
|
||||
func (r *jiraIssueWatchResetter) 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, &jiraIssueWatchResetter{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 ticket. 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("jira: task deleter not wired; reset unavailable")
|
||||
}
|
||||
res, err := watchreset.Run(ctx,
|
||||
&jiraIssueWatchResetter{store: s.store, watchID: watchID},
|
||||
td, s.log)
|
||||
return res.TasksDeleted, err
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
||||
@@ -627,3 +627,35 @@ func (s *Store) ListSeenIssueKeys(ctx context.Context, watchID string) (map[stri
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListIssueWatchTaskIDs returns every task_id recorded against a watch,
|
||||
// including the empty-string sentinel reservations that never completed.
|
||||
// Used by the reset flow to enumerate the tasks to cascade-delete.
|
||||
func (s *Store) ListIssueWatchTaskIDs(ctx context.Context, watchID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := s.ro.SelectContext(ctx, &ids,
|
||||
`SELECT task_id FROM jira_issue_watch_tasks WHERE issue_watch_id = ?`, watchID)
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ResetIssueWatchState wipes the watch's dedup rows and nulls its
|
||||
// last_polled_at in one transaction. Used by the reset flow after the
|
||||
// cascade-delete loop so the next poll re-imports every currently-matching
|
||||
// ticket as if the watch were freshly created.
|
||||
func (s *Store) ResetIssueWatchState(ctx context.Context, watchID 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 = ?`, watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE jira_issue_watches SET last_polled_at = NULL, updated_at = ? WHERE id = ?`,
|
||||
time.Now().UTC(), watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -267,6 +267,71 @@ func TestStore_IssueWatchTask_ListSeenIssueKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_IssueWatchTask_ListTaskIDsAndReset(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)
|
||||
}
|
||||
// Stamp a last_polled_at so we can assert the reset clears it.
|
||||
if err := store.UpdateIssueWatchLastPolled(ctx, w.ID, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("stamp last polled: %v", err)
|
||||
}
|
||||
// Reserve three slots; only two get assigned task IDs (mirrors the
|
||||
// "task creation in flight" state the reset must handle).
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-1", "task-a"); err != nil {
|
||||
t.Fatalf("assign 1: %v", err)
|
||||
}
|
||||
if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-2", "task-b"); err != nil {
|
||||
t.Fatalf("assign 2: %v", err)
|
||||
}
|
||||
|
||||
ids, err := store.ListIssueWatchTaskIDs(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids: %v", err)
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("ListIssueWatchTaskIDs returned %d rows, want 3 (including empty reservation)", len(ids))
|
||||
}
|
||||
nonEmpty := 0
|
||||
for _, id := range ids {
|
||||
if id != "" {
|
||||
nonEmpty++
|
||||
}
|
||||
}
|
||||
if nonEmpty != 2 {
|
||||
t.Errorf("expected 2 non-empty task IDs, got %d", nonEmpty)
|
||||
}
|
||||
|
||||
if err := store.ResetIssueWatchState(ctx, w.ID); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
|
||||
// Dedup rows wiped.
|
||||
idsAfter, err := store.ListIssueWatchTaskIDs(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids after reset: %v", err)
|
||||
}
|
||||
if len(idsAfter) != 0 {
|
||||
t.Errorf("expected 0 dedup rows after reset, got %d", len(idsAfter))
|
||||
}
|
||||
// last_polled_at cleared.
|
||||
got, err := store.GetIssueWatch(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get watch: %v", err)
|
||||
}
|
||||
if got.LastPolledAt != nil {
|
||||
t.Errorf("expected LastPolledAt to be nil after reset, got %v", got.LastPolledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_IssueWatchTask_Release(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -49,6 +49,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch)
|
||||
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
|
||||
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch)
|
||||
api.GET("/watches/issue/:id/reset/preview", c.httpPreviewResetIssueWatch)
|
||||
api.POST("/watches/issue/:id/reset", c.httpResetIssueWatch)
|
||||
}
|
||||
|
||||
// --- HTTP handlers ---
|
||||
|
||||
@@ -97,6 +97,39 @@ func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{"newIssues": len(issues)})
|
||||
}
|
||||
|
||||
// httpPreviewResetIssueWatch returns the count of tasks that a reset on the
|
||||
// watch would cascade-delete. Used by the confirmation dialog so the user
|
||||
// sees "delete N task(s)" before they commit.
|
||||
func (c *Controller) httpPreviewResetIssueWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.assertWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.PreviewResetIssueWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeIssueWatchError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"taskCount": n})
|
||||
}
|
||||
|
||||
// httpResetIssueWatch executes the destructive reset: cascade-deletes all
|
||||
// tasks previously created by the watch (including archived), wipes its
|
||||
// dedup table, and nulls last_polled_at so the next poll re-imports
|
||||
// everything currently matching the filter.
|
||||
func (c *Controller) httpResetIssueWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.assertWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.ResetIssueWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeIssueWatchError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"tasksDeleted": n})
|
||||
}
|
||||
|
||||
// assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the
|
||||
// caller must supply `?workspace_id=...` matching the watch's workspace.
|
||||
// Writes the response and returns false on mismatch.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/watchreset"
|
||||
)
|
||||
|
||||
// SecretStore is the subset of the secrets store the service needs.
|
||||
@@ -32,12 +33,25 @@ type Service struct {
|
||||
client Client // singleton, cleared on config change.
|
||||
probeHook func()
|
||||
eventBus bus.EventBus
|
||||
// taskDeleter is the cascade-delete entry point used by ResetIssueWatch.
|
||||
// Wired post-construction via SetTaskDeleter to avoid an import cycle
|
||||
// with the task service.
|
||||
taskDeleter watchreset.TaskDeleter
|
||||
// mockClient is non-nil only when Provide built the service with a MockClient
|
||||
// (KANDEV_MOCK_LINEAR=true). Exposed via MockClient() so the e2e control
|
||||
// routes can drive the same instance the clientFn returns.
|
||||
mockClient *MockClient
|
||||
}
|
||||
|
||||
// SetTaskDeleter wires the cascade-delete dependency used by ResetIssueWatch.
|
||||
// Optional — when unset, reset returns an error so the missing wiring is
|
||||
// surfaced instead of silently no-op'ing.
|
||||
func (s *Service) SetTaskDeleter(td watchreset.TaskDeleter) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.taskDeleter = td
|
||||
}
|
||||
|
||||
// MockClient returns the shared mock client when the service was built in mock
|
||||
// mode, or nil for production builds.
|
||||
func (s *Service) MockClient() *MockClient {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"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
|
||||
@@ -108,6 +109,45 @@ 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".
|
||||
|
||||
@@ -317,3 +317,35 @@ func (s *Store) ListSeenIssueIdentifiers(ctx context.Context, watchID string) (m
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListIssueWatchTaskIDs returns every task_id recorded against a watch,
|
||||
// including the empty-string sentinel reservations that never completed.
|
||||
// Used by the reset flow to enumerate the tasks to cascade-delete.
|
||||
func (s *Store) ListIssueWatchTaskIDs(ctx context.Context, watchID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := s.ro.SelectContext(ctx, &ids,
|
||||
`SELECT task_id FROM linear_issue_watch_tasks WHERE issue_watch_id = ?`, watchID)
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ResetIssueWatchState wipes the watch's dedup rows and nulls its
|
||||
// last_polled_at in one transaction. Used by the reset flow after the
|
||||
// cascade-delete loop so the next poll re-imports every currently-matching
|
||||
// issue as if the watch were freshly created.
|
||||
func (s *Store) ResetIssueWatchState(ctx context.Context, watchID 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 linear_issue_watch_tasks WHERE issue_watch_id = ?`, watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE linear_issue_watches SET last_polled_at = NULL, updated_at = ? WHERE id = ?`,
|
||||
time.Now().UTC(), watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -259,3 +259,65 @@ func TestStore_IssueWatchTask_Release(t *testing.T) {
|
||||
t.Error("expected reservation to succeed after release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_IssueWatchTask_ListTaskIDsAndReset(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.UpdateIssueWatchLastPolled(ctx, w.ID, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("stamp last polled: %v", err)
|
||||
}
|
||||
// Three reservations; only two get task IDs assigned so the third row
|
||||
// exercises the empty-ID inclusion the reset flow depends on.
|
||||
for _, k := range []string{"ENG-1", "ENG-2", "ENG-3"} {
|
||||
if _, err := store.ReserveIssueWatchTask(ctx, w.ID, k, "https://linear.app/x/issue/"+k); err != nil {
|
||||
t.Fatalf("reserve %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
if err := store.AssignIssueWatchTaskID(ctx, w.ID, "ENG-1", "task-a"); err != nil {
|
||||
t.Fatalf("assign 1: %v", err)
|
||||
}
|
||||
if err := store.AssignIssueWatchTaskID(ctx, w.ID, "ENG-2", "task-b"); err != nil {
|
||||
t.Fatalf("assign 2: %v", err)
|
||||
}
|
||||
|
||||
ids, err := store.ListIssueWatchTaskIDs(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids: %v", err)
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("ListIssueWatchTaskIDs returned %d rows, want 3 (including empty reservation)", len(ids))
|
||||
}
|
||||
nonEmpty := 0
|
||||
for _, id := range ids {
|
||||
if id != "" {
|
||||
nonEmpty++
|
||||
}
|
||||
}
|
||||
if nonEmpty != 2 {
|
||||
t.Errorf("expected 2 non-empty task IDs, got %d", nonEmpty)
|
||||
}
|
||||
|
||||
if err := store.ResetIssueWatchState(ctx, w.ID); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
|
||||
idsAfter, err := store.ListIssueWatchTaskIDs(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids after reset: %v", err)
|
||||
}
|
||||
if len(idsAfter) != 0 {
|
||||
t.Errorf("expected 0 dedup rows after reset, got %d", len(idsAfter))
|
||||
}
|
||||
got, err := store.GetIssueWatch(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get watch: %v", err)
|
||||
}
|
||||
if got.LastPolledAt != nil {
|
||||
t.Errorf("expected LastPolledAt to be nil after reset, got %v", got.LastPolledAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ func (c *Controller) registerIssueWatchRoutes(api *gin.RouterGroup) {
|
||||
api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch)
|
||||
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
|
||||
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch)
|
||||
api.GET("/watches/issue/:id/reset/preview", c.httpPreviewResetIssueWatch)
|
||||
api.POST("/watches/issue/:id/reset", c.httpResetIssueWatch)
|
||||
}
|
||||
|
||||
// httpListIssueWatches returns watches scoped to one workspace when
|
||||
@@ -123,6 +125,39 @@ func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{"published": len(issues)})
|
||||
}
|
||||
|
||||
// httpPreviewResetIssueWatch returns the count of tasks that a reset on the
|
||||
// watch would cascade-delete. Used by the confirmation dialog so the user
|
||||
// sees "delete N task(s)" before they commit.
|
||||
func (c *Controller) httpPreviewResetIssueWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.assertWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.PreviewResetIssueWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeIssueWatchError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"taskCount": n})
|
||||
}
|
||||
|
||||
// httpResetIssueWatch executes the destructive reset: cascade-deletes all
|
||||
// tasks previously created by the watch (including archived), wipes its
|
||||
// dedup table, and nulls last_polled_at so the next poll re-imports
|
||||
// everything currently matching the filter.
|
||||
func (c *Controller) httpResetIssueWatch(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
if !c.assertWatchInWorkspace(ctx, id) {
|
||||
return
|
||||
}
|
||||
n, err := c.service.ResetIssueWatch(ctx.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.writeIssueWatchError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"tasksDeleted": n})
|
||||
}
|
||||
|
||||
// assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the
|
||||
// caller must supply `?workspace_id=...` matching the watch's workspace.
|
||||
// Writes the response and returns false on mismatch.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/watchreset"
|
||||
)
|
||||
|
||||
// SecretStore is the subset of the secrets store the service needs.
|
||||
@@ -44,6 +45,19 @@ type Service struct {
|
||||
// NewSentryIssueEvent. Optional: when nil, observed issues are not
|
||||
// surfaced to the orchestrator.
|
||||
eventBus bus.EventBus
|
||||
// taskDeleter is the cascade-delete entry point used by ResetIssueWatch.
|
||||
// Wired post-construction via SetTaskDeleter to avoid an import cycle
|
||||
// with the task service.
|
||||
taskDeleter watchreset.TaskDeleter
|
||||
}
|
||||
|
||||
// SetTaskDeleter wires the cascade-delete dependency used by ResetIssueWatch.
|
||||
// Optional — when unset, reset returns an error so the missing wiring is
|
||||
// surfaced instead of silently no-op'ing.
|
||||
func (s *Service) SetTaskDeleter(td watchreset.TaskDeleter) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.taskDeleter = td
|
||||
}
|
||||
|
||||
// MockClient returns the shared mock client when the service was built in mock
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"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
|
||||
@@ -104,6 +105,45 @@ func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error {
|
||||
return s.store.DeleteIssueWatch(ctx, id)
|
||||
}
|
||||
|
||||
// sentryIssueWatchResetter is the watchreset.Resetter adapter for a single
|
||||
// Sentry issue watch. Closes over the store and watch ID so the shared
|
||||
// watchreset.Run helper stays integration-agnostic.
|
||||
type sentryIssueWatchResetter struct {
|
||||
store *Store
|
||||
watchID string
|
||||
}
|
||||
|
||||
func (r *sentryIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
|
||||
return r.store.ListIssueWatchTaskIDs(ctx, r.watchID)
|
||||
}
|
||||
|
||||
func (r *sentryIssueWatchResetter) 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, &sentryIssueWatchResetter{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("sentry: task deleter not wired; reset unavailable")
|
||||
}
|
||||
res, err := watchreset.Run(ctx,
|
||||
&sentryIssueWatchResetter{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".
|
||||
|
||||
@@ -316,3 +316,35 @@ func (s *Store) ListSeenIssueShortIDs(ctx context.Context, watchID string) (map[
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListIssueWatchTaskIDs returns every task_id recorded against a watch,
|
||||
// including the empty-string sentinel reservations that never completed.
|
||||
// Used by the reset flow to enumerate the tasks to cascade-delete.
|
||||
func (s *Store) ListIssueWatchTaskIDs(ctx context.Context, watchID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := s.ro.SelectContext(ctx, &ids,
|
||||
`SELECT task_id FROM sentry_issue_watch_tasks WHERE issue_watch_id = ?`, watchID)
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ResetIssueWatchState wipes the watch's dedup rows and nulls its
|
||||
// last_polled_at in one transaction. Used by the reset flow after the
|
||||
// cascade-delete loop so the next poll re-imports every currently-matching
|
||||
// issue as if the watch were freshly created.
|
||||
func (s *Store) ResetIssueWatchState(ctx context.Context, watchID 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 sentry_issue_watch_tasks WHERE issue_watch_id = ?`, watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE sentry_issue_watches SET last_polled_at = NULL, updated_at = ? WHERE id = ?`,
|
||||
time.Now().UTC(), watchID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -262,3 +262,65 @@ func TestStore_IssueWatchTask_Release(t *testing.T) {
|
||||
t.Error("expected reservation to succeed after release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_IssueWatchTask_ListTaskIDsAndReset(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.UpdateIssueWatchLastPolled(ctx, w.ID, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("stamp last polled: %v", err)
|
||||
}
|
||||
// Three reservations; only two get task IDs assigned so the third row
|
||||
// exercises the empty-ID inclusion the reset flow depends on.
|
||||
for _, k := range []string{"PROJ-1", "PROJ-2", "PROJ-3"} {
|
||||
if _, err := store.ReserveIssueWatchTask(ctx, w.ID, k, "https://sentry.io/issues/"+k); err != nil {
|
||||
t.Fatalf("reserve %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-1", "task-a"); err != nil {
|
||||
t.Fatalf("assign 1: %v", err)
|
||||
}
|
||||
if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-2", "task-b"); err != nil {
|
||||
t.Fatalf("assign 2: %v", err)
|
||||
}
|
||||
|
||||
ids, err := store.ListIssueWatchTaskIDs(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids: %v", err)
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("ListIssueWatchTaskIDs returned %d rows, want 3 (including empty reservation)", len(ids))
|
||||
}
|
||||
nonEmpty := 0
|
||||
for _, id := range ids {
|
||||
if id != "" {
|
||||
nonEmpty++
|
||||
}
|
||||
}
|
||||
if nonEmpty != 2 {
|
||||
t.Errorf("expected 2 non-empty task IDs, got %d", nonEmpty)
|
||||
}
|
||||
|
||||
if err := store.ResetIssueWatchState(ctx, w.ID); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
|
||||
idsAfter, err := store.ListIssueWatchTaskIDs(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list ids after reset: %v", err)
|
||||
}
|
||||
if len(idsAfter) != 0 {
|
||||
t.Errorf("expected 0 dedup rows after reset, got %d", len(idsAfter))
|
||||
}
|
||||
got, err := store.GetIssueWatch(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get watch: %v", err)
|
||||
}
|
||||
if got.LastPolledAt != nil {
|
||||
t.Errorf("expected LastPolledAt to be nil after reset, got %v", got.LastPolledAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -81,10 +75,10 @@ func (c *Collector) sampleMetric(ctx context.Context, id string, diskPath string
|
||||
value, err := diskPercent(ctx, diskPath)
|
||||
return sample(id, "Disk", "%", value, err)
|
||||
case MetricCPUTemp:
|
||||
value, err := cpuTemp(c.sysRoot)
|
||||
value, err := c.cpuTempValue()
|
||||
return sample(id, "CPU temp", "C", value, err)
|
||||
case MetricIOLoad:
|
||||
value, err := ioLoad(c.procRoot)
|
||||
value, err := c.ioLoadValue()
|
||||
return sample(id, "Load avg", "", value, err)
|
||||
default:
|
||||
return MetricSample{ID: id, Label: id, Available: false, Error: "unknown metric"}
|
||||
@@ -146,7 +140,7 @@ type cpuTimes struct {
|
||||
}
|
||||
|
||||
func (c *Collector) cpuPercent() (float64, error) {
|
||||
current, err := readProcStat(filepath.Join(c.procRoot, "stat"))
|
||||
current, err := readCPUTimes(c)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -165,30 +159,6 @@ func (c *Collector) cpuPercent() (float64, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func readProcStat(path string) (cpuTimes, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cpuTimes{}, err
|
||||
}
|
||||
fields := strings.Fields(strings.SplitN(string(data), "\n", 2)[0])
|
||||
if len(fields) < 8 || fields[0] != "cpu" {
|
||||
return cpuTimes{}, errors.New("invalid /proc/stat cpu line")
|
||||
}
|
||||
var total uint64
|
||||
var idle uint64
|
||||
for i, field := range fields[1:] {
|
||||
v, err := strconv.ParseUint(field, 10, 64)
|
||||
if err != nil {
|
||||
return cpuTimes{}, err
|
||||
}
|
||||
total += v
|
||||
if i == 3 || i == 4 {
|
||||
idle += v
|
||||
}
|
||||
}
|
||||
return cpuTimes{total: total, idle: idle}, nil
|
||||
}
|
||||
|
||||
func calculateCPUPercent(prev, current cpuTimes) float64 {
|
||||
totalDelta := current.total - prev.total
|
||||
if totalDelta == 0 {
|
||||
@@ -198,101 +168,6 @@ func calculateCPUPercent(prev, current cpuTimes) float64 {
|
||||
return (1 - float64(idleDelta)/float64(totalDelta)) * 100
|
||||
}
|
||||
|
||||
func (c *Collector) memoryPercent() (float64, error) {
|
||||
if value, ok := c.cgroupMemoryPercent(); ok {
|
||||
return value, nil
|
||||
}
|
||||
return memInfoPercent(filepath.Join(c.procRoot, "meminfo"))
|
||||
}
|
||||
|
||||
func (c *Collector) cgroupMemoryPercent() (float64, bool) {
|
||||
usage, err := readUintFile(filepath.Join(c.cgroupRoot, "memory.current"))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
maxRaw, err := os.ReadFile(filepath.Join(c.cgroupRoot, "memory.max"))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
maxText := strings.TrimSpace(string(maxRaw))
|
||||
if maxText == "max" {
|
||||
return 0, false
|
||||
}
|
||||
limit, err := strconv.ParseUint(maxText, 10, 64)
|
||||
if err != nil || limit == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(usage) / float64(limit) * 100, true
|
||||
}
|
||||
|
||||
func memInfoPercent(path string) (float64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
values := map[string]uint64{}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
v, err := strconv.ParseUint(fields[1], 10, 64)
|
||||
if err == nil {
|
||||
values[key] = v
|
||||
}
|
||||
}
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
if total == 0 {
|
||||
return 0, errors.New("MemTotal missing")
|
||||
}
|
||||
if available == 0 {
|
||||
available = values["MemFree"]
|
||||
}
|
||||
return (1 - float64(available)/float64(total)) * 100, nil
|
||||
}
|
||||
|
||||
func cpuTemp(sysRoot string) (float64, error) {
|
||||
paths, err := filepath.Glob(filepath.Join(sysRoot, "class/thermal/thermal_zone*/temp"))
|
||||
if err != nil || len(paths) == 0 {
|
||||
return 0, errors.New("cpu temperature unavailable")
|
||||
}
|
||||
for _, path := range paths {
|
||||
raw, err := readUintFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
value := float64(raw)
|
||||
if value > 1000 {
|
||||
value /= 1000
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
return 0, errors.New("cpu temperature unavailable")
|
||||
}
|
||||
|
||||
func ioLoad(procRoot string) (float64, error) {
|
||||
data, err := os.ReadFile(filepath.Join(procRoot, "loadavg"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0, errors.New("loadavg missing")
|
||||
}
|
||||
return strconv.ParseFloat(fields[0], 64)
|
||||
}
|
||||
|
||||
func readUintFile(path string) (uint64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
|
||||
}
|
||||
|
||||
func (c *Collector) SampleWithTimestamp(ctx context.Context, settings GlobalSettings) Snapshot {
|
||||
source := c.Sample(ctx, settings.Metrics, settings.BackendDiskPath)
|
||||
return Snapshot{
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build darwin
|
||||
|
||||
package metrics
|
||||
|
||||
/*
|
||||
#include <mach/mach.h>
|
||||
#include <mach/mach_host.h>
|
||||
#include <mach/host_info.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// readCPUTimes uses Mach host_statistics(HOST_CPU_LOAD_INFO). Darwin does not
|
||||
// expose kern.cp_time the way BSDs do, so we go straight to Mach. The returned
|
||||
// cpu_ticks array has CPU_STATE_MAX entries (USER, SYSTEM, IDLE, NICE) of
|
||||
// natural_t (uint32). Wraparound is rare at 100 Hz (~497 days) and the delta
|
||||
// arithmetic in calculateCPUPercent already tolerates it via uint64 promotion.
|
||||
func readCPUTimes(_ *Collector) (cpuTimes, error) {
|
||||
var info C.host_cpu_load_info_data_t
|
||||
count := C.mach_msg_type_number_t(C.HOST_CPU_LOAD_INFO_COUNT)
|
||||
rc := C.host_statistics(
|
||||
C.mach_host_self(),
|
||||
C.HOST_CPU_LOAD_INFO,
|
||||
C.host_info_t(unsafe.Pointer(&info)),
|
||||
&count,
|
||||
)
|
||||
if rc != C.KERN_SUCCESS {
|
||||
return cpuTimes{}, fmt.Errorf("host_statistics HOST_CPU_LOAD_INFO: kern_return %d", int(rc))
|
||||
}
|
||||
var total, idle uint64
|
||||
for i := 0; i < int(C.CPU_STATE_MAX); i++ {
|
||||
v := uint64(info.cpu_ticks[i])
|
||||
total += v
|
||||
if i == int(C.CPU_STATE_IDLE) {
|
||||
idle = v
|
||||
}
|
||||
}
|
||||
return cpuTimes{total: total, idle: idle}, nil
|
||||
}
|
||||
|
||||
func (c *Collector) memoryPercent() (float64, error) {
|
||||
return darwinMemoryPercent()
|
||||
}
|
||||
|
||||
func (c *Collector) cpuTempValue() (float64, error) {
|
||||
return 0, errors.New("cpu temperature unavailable on darwin")
|
||||
}
|
||||
|
||||
func (c *Collector) ioLoadValue() (float64, error) {
|
||||
return darwinLoadAvg1()
|
||||
}
|
||||
|
||||
// vm.loadavg returns struct loadavg { fixed_pt_t ldavg[3]; long fscale; }.
|
||||
// On darwin amd64/arm64 long is 8 bytes; the struct is padded to 24 bytes.
|
||||
func darwinLoadAvg1() (float64, error) {
|
||||
raw, err := unix.SysctlRaw("vm.loadavg")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sysctl vm.loadavg: %w", err)
|
||||
}
|
||||
if len(raw) < 24 {
|
||||
return 0, errors.New("sysctl vm.loadavg response too short")
|
||||
}
|
||||
ld0 := binary.LittleEndian.Uint32(raw[0:4])
|
||||
fscale := binary.LittleEndian.Uint64(raw[16:24])
|
||||
if fscale == 0 {
|
||||
return 0, errors.New("sysctl vm.loadavg fscale is zero")
|
||||
}
|
||||
return float64(ld0) / float64(fscale), nil
|
||||
}
|
||||
|
||||
// darwinMemoryPercent treats (free + inactive + speculative) pages as
|
||||
// available, matching Activity Monitor's "Memory Used / Total" approximation.
|
||||
// Wired, active, and compressor pages count as used.
|
||||
func darwinMemoryPercent() (float64, error) {
|
||||
total, err := unix.SysctlUint64("hw.memsize")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sysctl hw.memsize: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, errors.New("hw.memsize is zero")
|
||||
}
|
||||
pageSize := uint64(os.Getpagesize())
|
||||
if pageSize == 0 {
|
||||
return 0, errors.New("page size is zero")
|
||||
}
|
||||
free, inactive, speculative, err := readVMStatistics64()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
available := (free + inactive + speculative) * pageSize
|
||||
if available > total {
|
||||
available = total
|
||||
}
|
||||
return (1.0 - float64(available)/float64(total)) * 100.0, nil
|
||||
}
|
||||
|
||||
func readVMStatistics64() (free, inactive, speculative uint64, err error) {
|
||||
var vm C.vm_statistics64_data_t
|
||||
count := C.mach_msg_type_number_t(C.HOST_VM_INFO64_COUNT)
|
||||
rc := C.host_statistics64(
|
||||
C.mach_host_self(),
|
||||
C.HOST_VM_INFO64,
|
||||
C.host_info64_t(unsafe.Pointer(&vm)),
|
||||
&count,
|
||||
)
|
||||
if rc != C.KERN_SUCCESS {
|
||||
return 0, 0, 0, fmt.Errorf("host_statistics64 HOST_VM_INFO64: kern_return %d", int(rc))
|
||||
}
|
||||
return uint64(vm.free_count), uint64(vm.inactive_count), uint64(vm.speculative_count), nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build darwin
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDarwinReadCPUTimes(t *testing.T) {
|
||||
got, err := readCPUTimes(NewCollector())
|
||||
if err != nil {
|
||||
t.Fatalf("readCPUTimes: %v", err)
|
||||
}
|
||||
if got.total == 0 {
|
||||
t.Fatal("expected total cpu ticks > 0")
|
||||
}
|
||||
if got.idle > got.total {
|
||||
t.Fatalf("idle=%d > total=%d", got.idle, got.total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinCPUPercentNonNegative(t *testing.T) {
|
||||
c := NewCollector()
|
||||
if _, err := c.cpuPercent(); err != nil {
|
||||
t.Fatalf("first cpuPercent (baseline): %v", err)
|
||||
}
|
||||
v, err := c.cpuPercent()
|
||||
if err != nil {
|
||||
t.Fatalf("second cpuPercent: %v", err)
|
||||
}
|
||||
if v < 0 || v > 100 {
|
||||
t.Fatalf("cpu percent=%v, want 0..100", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinLoadAvg1(t *testing.T) {
|
||||
v, err := darwinLoadAvg1()
|
||||
if err != nil {
|
||||
t.Fatalf("darwinLoadAvg1: %v", err)
|
||||
}
|
||||
if v < 0 {
|
||||
t.Fatalf("loadavg=%v, want >= 0", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinMemoryPercent(t *testing.T) {
|
||||
v, err := darwinMemoryPercent()
|
||||
if err != nil {
|
||||
t.Fatalf("darwinMemoryPercent: %v", err)
|
||||
}
|
||||
if v < 0 || v > 100 {
|
||||
t.Fatalf("memory percent=%v, want 0..100", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinCPUTempUnavailable(t *testing.T) {
|
||||
if _, err := NewCollector().cpuTempValue(); err == nil {
|
||||
t.Fatal("expected cpu temperature to be unavailable on darwin")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//go:build linux
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func readCPUTimes(c *Collector) (cpuTimes, error) {
|
||||
return readProcStat(filepath.Join(c.procRoot, "stat"))
|
||||
}
|
||||
|
||||
func (c *Collector) memoryPercent() (float64, error) {
|
||||
if value, ok := c.cgroupMemoryPercent(); ok {
|
||||
return value, nil
|
||||
}
|
||||
return memInfoPercent(filepath.Join(c.procRoot, "meminfo"))
|
||||
}
|
||||
|
||||
func (c *Collector) cpuTempValue() (float64, error) {
|
||||
return cpuTemp(c.sysRoot)
|
||||
}
|
||||
|
||||
func (c *Collector) ioLoadValue() (float64, error) {
|
||||
return ioLoad(c.procRoot)
|
||||
}
|
||||
|
||||
func readProcStat(path string) (cpuTimes, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cpuTimes{}, err
|
||||
}
|
||||
fields := strings.Fields(strings.SplitN(string(data), "\n", 2)[0])
|
||||
if len(fields) < 8 || fields[0] != "cpu" {
|
||||
return cpuTimes{}, errors.New("invalid /proc/stat cpu line")
|
||||
}
|
||||
var total uint64
|
||||
var idle uint64
|
||||
for i, field := range fields[1:] {
|
||||
v, err := strconv.ParseUint(field, 10, 64)
|
||||
if err != nil {
|
||||
return cpuTimes{}, err
|
||||
}
|
||||
total += v
|
||||
if i == 3 || i == 4 {
|
||||
idle += v
|
||||
}
|
||||
}
|
||||
return cpuTimes{total: total, idle: idle}, nil
|
||||
}
|
||||
|
||||
func (c *Collector) cgroupMemoryPercent() (float64, bool) {
|
||||
usage, err := readUintFile(filepath.Join(c.cgroupRoot, "memory.current"))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
maxRaw, err := os.ReadFile(filepath.Join(c.cgroupRoot, "memory.max"))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
maxText := strings.TrimSpace(string(maxRaw))
|
||||
if maxText == "max" {
|
||||
return 0, false
|
||||
}
|
||||
limit, err := strconv.ParseUint(maxText, 10, 64)
|
||||
if err != nil || limit == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(usage) / float64(limit) * 100, true
|
||||
}
|
||||
|
||||
func memInfoPercent(path string) (float64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
values := map[string]uint64{}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
v, err := strconv.ParseUint(fields[1], 10, 64)
|
||||
if err == nil {
|
||||
values[key] = v
|
||||
}
|
||||
}
|
||||
total := values["MemTotal"]
|
||||
if total == 0 {
|
||||
return 0, errors.New("MemTotal missing")
|
||||
}
|
||||
available, ok := values["MemAvailable"]
|
||||
if !ok {
|
||||
available = values["MemFree"]
|
||||
}
|
||||
return (1 - float64(available)/float64(total)) * 100, nil
|
||||
}
|
||||
|
||||
func cpuTemp(sysRoot string) (float64, error) {
|
||||
paths, err := filepath.Glob(filepath.Join(sysRoot, "class/thermal/thermal_zone*/temp"))
|
||||
if err != nil || len(paths) == 0 {
|
||||
return 0, errors.New("cpu temperature unavailable")
|
||||
}
|
||||
for _, path := range paths {
|
||||
raw, err := readUintFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
value := float64(raw)
|
||||
if value > 1000 {
|
||||
value /= 1000
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
return 0, errors.New("cpu temperature unavailable")
|
||||
}
|
||||
|
||||
func ioLoad(procRoot string) (float64, error) {
|
||||
data, err := os.ReadFile(filepath.Join(procRoot, "loadavg"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0, errors.New("loadavg missing")
|
||||
}
|
||||
return strconv.ParseFloat(fields[0], 64)
|
||||
}
|
||||
|
||||
func readUintFile(path string) (uint64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package metrics
|
||||
|
||||
import "errors"
|
||||
|
||||
func readCPUTimes(_ *Collector) (cpuTimes, error) {
|
||||
return cpuTimes{}, errors.New("cpu metrics unavailable on this platform")
|
||||
}
|
||||
|
||||
func (c *Collector) memoryPercent() (float64, error) {
|
||||
return 0, errors.New("memory metrics unavailable on this platform")
|
||||
}
|
||||
|
||||
func (c *Collector) cpuTempValue() (float64, error) {
|
||||
return 0, errors.New("cpu temperature unavailable on this platform")
|
||||
}
|
||||
|
||||
func (c *Collector) ioLoadValue() (float64, error) {
|
||||
return 0, errors.New("load average unavailable on this platform")
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build windows
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
|
||||
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
|
||||
)
|
||||
|
||||
// memoryStatusEx mirrors MEMORYSTATUSEX from windows.h. The first field is the
|
||||
// struct length in bytes and must be set by the caller before invoking
|
||||
// GlobalMemoryStatusEx.
|
||||
type memoryStatusEx struct {
|
||||
Length uint32
|
||||
MemoryLoad uint32
|
||||
TotalPhys uint64
|
||||
AvailPhys uint64
|
||||
TotalPageFile uint64
|
||||
AvailPageFile uint64
|
||||
TotalVirtual uint64
|
||||
AvailVirtual uint64
|
||||
AvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
// readCPUTimes uses GetSystemTimes. On Windows kernelTime includes idleTime,
|
||||
// so total non-idle = (kernel + user) - idle and we return total = kernel + user.
|
||||
func readCPUTimes(_ *Collector) (cpuTimes, error) {
|
||||
var idle, kernel, user windows.Filetime
|
||||
r1, _, e := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idle)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)),
|
||||
)
|
||||
if r1 == 0 {
|
||||
return cpuTimes{}, fmt.Errorf("GetSystemTimes: %w", e)
|
||||
}
|
||||
return cpuTimes{
|
||||
total: filetimeToUint64(kernel) + filetimeToUint64(user),
|
||||
idle: filetimeToUint64(idle),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func filetimeToUint64(ft windows.Filetime) uint64 {
|
||||
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
|
||||
}
|
||||
|
||||
func (c *Collector) memoryPercent() (float64, error) {
|
||||
var status memoryStatusEx
|
||||
status.Length = uint32(unsafe.Sizeof(status))
|
||||
r1, _, e := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&status)))
|
||||
if r1 == 0 {
|
||||
return 0, fmt.Errorf("GlobalMemoryStatusEx: %w", e)
|
||||
}
|
||||
if status.TotalPhys == 0 {
|
||||
return 0, errors.New("total physical memory is zero")
|
||||
}
|
||||
used := status.TotalPhys - status.AvailPhys
|
||||
return float64(used) / float64(status.TotalPhys) * 100, nil
|
||||
}
|
||||
|
||||
func (c *Collector) cpuTempValue() (float64, error) {
|
||||
return 0, errors.New("cpu temperature unavailable on windows")
|
||||
}
|
||||
|
||||
func (c *Collector) ioLoadValue() (float64, error) {
|
||||
return 0, errors.New("load average unavailable on windows")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build windows
|
||||
|
||||
package metrics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWindowsReadCPUTimes(t *testing.T) {
|
||||
got, err := readCPUTimes(NewCollector())
|
||||
if err != nil {
|
||||
t.Fatalf("readCPUTimes: %v", err)
|
||||
}
|
||||
if got.total == 0 {
|
||||
t.Fatal("expected total cpu time > 0")
|
||||
}
|
||||
if got.idle > got.total {
|
||||
t.Fatalf("idle=%d > total=%d", got.idle, got.total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsCPUPercentNonNegative(t *testing.T) {
|
||||
c := NewCollector()
|
||||
if _, err := c.cpuPercent(); err != nil {
|
||||
t.Fatalf("first cpuPercent (baseline): %v", err)
|
||||
}
|
||||
v, err := c.cpuPercent()
|
||||
if err != nil {
|
||||
t.Fatalf("second cpuPercent: %v", err)
|
||||
}
|
||||
if v < 0 || v > 100 {
|
||||
t.Fatalf("cpu percent=%v, want 0..100", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsMemoryPercent(t *testing.T) {
|
||||
v, err := NewCollector().memoryPercent()
|
||||
if err != nil {
|
||||
t.Fatalf("memoryPercent: %v", err)
|
||||
}
|
||||
if v < 0 || v > 100 {
|
||||
t.Fatalf("memory percent=%v, want 0..100", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsLoadAvgUnavailable(t *testing.T) {
|
||||
if _, err := NewCollector().ioLoadValue(); err == nil {
|
||||
t.Fatal("expected load average to be unavailable on windows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsCPUTempUnavailable(t *testing.T) {
|
||||
if _, err := NewCollector().cpuTempValue(); err == nil {
|
||||
t.Fatal("expected cpu temperature to be unavailable on windows")
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,55 @@ package metrics
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func diskPercent(_ context.Context, _ string) (float64, error) {
|
||||
return 0, errors.New("disk usage unavailable on windows")
|
||||
const diskQueryTimeout = 2 * time.Second
|
||||
|
||||
// getDiskFreeSpaceEx is a package-level seam so tests can substitute a fake.
|
||||
var getDiskFreeSpaceEx = windows.GetDiskFreeSpaceEx
|
||||
|
||||
type diskQueryResult struct {
|
||||
totalBytes uint64
|
||||
freeBytes uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func diskPercent(ctx context.Context, path string) (float64, error) {
|
||||
dir, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("convert path %q: %w", path, err)
|
||||
}
|
||||
|
||||
result := make(chan diskQueryResult, 1)
|
||||
go func() {
|
||||
var freeToCaller, totalBytes, totalFree uint64
|
||||
callErr := getDiskFreeSpaceEx(dir, &freeToCaller, &totalBytes, &totalFree)
|
||||
result <- diskQueryResult{totalBytes: totalBytes, freeBytes: totalFree, err: callErr}
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(diskQueryTimeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
case <-timer.C:
|
||||
return 0, fmt.Errorf("disk usage timed out for %q", path)
|
||||
case res := <-result:
|
||||
if res.err != nil {
|
||||
return 0, fmt.Errorf("GetDiskFreeSpaceEx %q: %w", path, res.err)
|
||||
}
|
||||
return diskPercentFromBytes(res.totalBytes, res.freeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func diskPercentFromBytes(total, free uint64) (float64, error) {
|
||||
if total == 0 {
|
||||
return 0, errors.New("disk total is zero")
|
||||
}
|
||||
return (1 - float64(free)/float64(total)) * 100, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build windows
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDiskPercentReturnsWhenContextCancelsWhileQueryBlocks(t *testing.T) {
|
||||
original := getDiskFreeSpaceEx
|
||||
block := make(chan struct{})
|
||||
started := make(chan struct{})
|
||||
getDiskFreeSpaceEx = func(_ *uint16, _ *uint64, _ *uint64, _ *uint64) error {
|
||||
close(started)
|
||||
<-block
|
||||
return errors.New("unblocked")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
close(block)
|
||||
getDiskFreeSpaceEx = original
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := diskPercent(ctx, `C:\`)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("GetDiskFreeSpaceEx was not called")
|
||||
}
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("diskPercent error = %v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("diskPercent did not return after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskPercentLive(t *testing.T) {
|
||||
v, err := diskPercent(context.Background(), `C:\`)
|
||||
if err != nil {
|
||||
t.Fatalf("diskPercent: %v", err)
|
||||
}
|
||||
if v < 0 || v > 100 {
|
||||
t.Fatalf("disk percent=%v, want 0..100", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskPercentFromBytes(t *testing.T) {
|
||||
if _, err := diskPercentFromBytes(0, 0); err == nil {
|
||||
t.Fatal("expected error when total is zero")
|
||||
}
|
||||
v, err := diskPercentFromBytes(100, 25)
|
||||
if err != nil {
|
||||
t.Fatalf("diskPercentFromBytes: %v", err)
|
||||
}
|
||||
if v != 75 {
|
||||
t.Fatalf("got %v, want 75", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package watchreset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
type fakeResetter struct {
|
||||
ids []string
|
||||
listErr error
|
||||
cleared bool
|
||||
clearErr error
|
||||
}
|
||||
|
||||
func (f *fakeResetter) ListTaskIDs(_ context.Context) ([]string, error) {
|
||||
return f.ids, f.listErr
|
||||
}
|
||||
func (f *fakeResetter) Clear(_ context.Context) error {
|
||||
f.cleared = true
|
||||
return f.clearErr
|
||||
}
|
||||
|
||||
type fakeDeleter struct {
|
||||
called []string
|
||||
failOn map[string]error
|
||||
outcome *taskservice.CascadeOutcome
|
||||
}
|
||||
|
||||
func (f *fakeDeleter) DeleteTaskTree(_ context.Context, id string, _ bool) (*taskservice.CascadeOutcome, error) {
|
||||
f.called = append(f.called, id)
|
||||
if err, ok := f.failOn[id]; ok {
|
||||
return nil, err
|
||||
}
|
||||
return f.outcome, nil
|
||||
}
|
||||
|
||||
func TestRun_DeletesEveryNonEmptyIDAndClears(t *testing.T) {
|
||||
r := &fakeResetter{ids: []string{"a", "b", "", "c"}}
|
||||
td := &fakeDeleter{}
|
||||
res, err := Run(context.Background(), r, td, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.TasksDeleted != 3 {
|
||||
t.Fatalf("TasksDeleted=%d want 3", res.TasksDeleted)
|
||||
}
|
||||
if got := len(td.called); got != 3 {
|
||||
t.Fatalf("DeleteTaskTree calls=%d want 3", got)
|
||||
}
|
||||
if !r.cleared {
|
||||
t.Fatalf("Clear not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ContinuesOnDeleteFailure(t *testing.T) {
|
||||
r := &fakeResetter{ids: []string{"ok1", "bad", "ok2"}}
|
||||
td := &fakeDeleter{failOn: map[string]error{"bad": errors.New("boom")}}
|
||||
res, err := Run(context.Background(), r, td, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.TasksDeleted != 2 {
|
||||
t.Fatalf("TasksDeleted=%d want 2 (skip failing)", res.TasksDeleted)
|
||||
}
|
||||
if !r.cleared {
|
||||
t.Fatalf("Clear must still run even when a delete failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_PropagatesListError(t *testing.T) {
|
||||
r := &fakeResetter{listErr: errors.New("db down")}
|
||||
td := &fakeDeleter{}
|
||||
_, err := Run(context.Background(), r, td, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected list error to surface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_PropagatesClearError(t *testing.T) {
|
||||
r := &fakeResetter{ids: []string{"a"}, clearErr: errors.New("write fail")}
|
||||
td := &fakeDeleter{}
|
||||
res, err := Run(context.Background(), r, td, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected clear error to surface")
|
||||
}
|
||||
if res.TasksDeleted != 1 {
|
||||
t.Fatalf("partial TasksDeleted should still be reported, got %d", res.TasksDeleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreview_CountsOnlyNonEmptyIDs(t *testing.T) {
|
||||
r := &fakeResetter{ids: []string{"", "a", "", "b", "c"}}
|
||||
n, err := Preview(context.Background(), r)
|
||||
if err != nil {
|
||||
t.Fatalf("Preview: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("n=%d want 3", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RejectsNilArgs(t *testing.T) {
|
||||
if _, err := Run(context.Background(), nil, &fakeDeleter{}, nil); err == nil {
|
||||
t.Fatalf("nil Resetter must error")
|
||||
}
|
||||
if _, err := Run(context.Background(), &fakeResetter{}, nil, nil); err == nil {
|
||||
t.Fatalf("nil TaskDeleter must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_RejectsAlreadyCancelledContext pins the ctx.Err() pre-check:
|
||||
// WithoutCancel intentionally drops every cancellation signal, so without
|
||||
// this guard a caller passing an already-expired ctx (deadline missed
|
||||
// before Run was even invoked) would silently get a destructive reset.
|
||||
func TestRun_RejectsAlreadyCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
r := &fakeResetter{ids: []string{"a"}}
|
||||
td := &fakeDeleter{}
|
||||
_, err := Run(ctx, r, td, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for already-cancelled context")
|
||||
}
|
||||
if r.cleared {
|
||||
t.Fatalf("Clear must not run when context is already cancelled")
|
||||
}
|
||||
if len(td.called) != 0 {
|
||||
t.Fatalf("DeleteTaskTree must not run when context is already cancelled, got %d calls", len(td.called))
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { PRStatsPanel } from "./pr-stats";
|
||||
import { useReviewWatches } from "@/hooks/domains/github/use-review-watches";
|
||||
import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches";
|
||||
import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section";
|
||||
import { ResetWatchDialog, useWatchResetController } from "@/components/watches/reset-watch-dialog";
|
||||
import { cleanupMergedReviewTasks, cleanupClosedIssueTasks } from "@/lib/api/domains/github-api";
|
||||
import type { ReviewWatch, IssueWatch } from "@/lib/types/github";
|
||||
|
||||
@@ -66,7 +67,15 @@ function CleanupNowButton({
|
||||
}
|
||||
|
||||
function useWatchActions(workspaceId?: string | null) {
|
||||
const { items: watches, create, update, remove, trigger } = useReviewWatches(workspaceId);
|
||||
const {
|
||||
items: watches,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
trigger,
|
||||
previewReset,
|
||||
reset,
|
||||
} = useReviewWatches(workspaceId);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
@@ -116,11 +125,47 @@ function useWatchActions(workspaceId?: string | null) {
|
||||
[update, toast],
|
||||
);
|
||||
|
||||
return { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled };
|
||||
const handleReset = useCallback(
|
||||
async (id: string, workspaceId: string) => {
|
||||
try {
|
||||
const { tasksDeleted } = await reset(id, workspaceId);
|
||||
toast({
|
||||
description:
|
||||
tasksDeleted > 0
|
||||
? `Reset complete — deleted ${tasksDeleted} task(s); next poll will re-import.`
|
||||
: "Reset complete — next poll will re-import matches.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch {
|
||||
toast({ description: "Failed to reset review watch", variant: "error" });
|
||||
throw new Error("reset failed");
|
||||
}
|
||||
},
|
||||
[reset, toast],
|
||||
);
|
||||
|
||||
return {
|
||||
watches,
|
||||
create,
|
||||
update,
|
||||
previewReset,
|
||||
handleDelete,
|
||||
handleTrigger,
|
||||
handleReset,
|
||||
handleToggleEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
function useIssueWatchActions(workspaceId?: string | null) {
|
||||
const { items: watches, create, update, remove, trigger } = useIssueWatches(workspaceId);
|
||||
const {
|
||||
items: watches,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
trigger,
|
||||
previewReset,
|
||||
reset,
|
||||
} = useIssueWatches(workspaceId);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
@@ -170,7 +215,35 @@ function useIssueWatchActions(workspaceId?: string | null) {
|
||||
[update, toast],
|
||||
);
|
||||
|
||||
return { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled };
|
||||
const handleReset = useCallback(
|
||||
async (id: string, workspaceId: string) => {
|
||||
try {
|
||||
const { tasksDeleted } = await reset(id, workspaceId);
|
||||
toast({
|
||||
description:
|
||||
tasksDeleted > 0
|
||||
? `Reset complete — deleted ${tasksDeleted} task(s); next poll will re-import.`
|
||||
: "Reset complete — next poll will re-import matches.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch {
|
||||
toast({ description: "Failed to reset issue watch", variant: "error" });
|
||||
throw new Error("reset failed");
|
||||
}
|
||||
},
|
||||
[reset, toast],
|
||||
);
|
||||
|
||||
return {
|
||||
watches,
|
||||
create,
|
||||
update,
|
||||
previewReset,
|
||||
handleDelete,
|
||||
handleTrigger,
|
||||
handleReset,
|
||||
handleToggleEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function GitHubConnectionSection() {
|
||||
@@ -232,17 +305,38 @@ export function GitHubIntegrationPage() {
|
||||
// single flat table. Pass `undefined` to the hook so it fetches all rows; the
|
||||
// dialog's create flow asks the user to pick the workspace.
|
||||
function ReviewWatchSection() {
|
||||
const { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled } =
|
||||
useWatchActions(undefined);
|
||||
const {
|
||||
watches,
|
||||
create,
|
||||
update,
|
||||
previewReset,
|
||||
handleDelete,
|
||||
handleTrigger,
|
||||
handleReset,
|
||||
handleToggleEnabled,
|
||||
} = useWatchActions(undefined);
|
||||
const { toast } = useToast();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingWatch, setEditingWatch] = useState<ReviewWatch | null>(null);
|
||||
const resetCtrl = useWatchResetController<ReviewWatch>({
|
||||
preview: (w) => previewReset(w.id, w.workspace_id),
|
||||
reset: (w) => handleReset(w.id, w.workspace_id),
|
||||
});
|
||||
|
||||
const handleEdit = useCallback((watch: ReviewWatch) => {
|
||||
setEditingWatch(watch);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const { setResetting: setReviewResetting } = resetCtrl;
|
||||
const onResetClick = useCallback(
|
||||
(id: string) => {
|
||||
const w = watches.find((item) => item.id === id);
|
||||
if (w) setReviewResetting(w);
|
||||
},
|
||||
[watches, setReviewResetting],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
@@ -273,6 +367,7 @@ function ReviewWatchSection() {
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onTrigger={handleTrigger}
|
||||
onReset={onResetClick}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -292,6 +387,15 @@ function ReviewWatchSection() {
|
||||
toast({ description: "Review watch updated", variant: "success" });
|
||||
}}
|
||||
/>
|
||||
{resetCtrl.resetting && (
|
||||
<ResetWatchDialog
|
||||
open
|
||||
onOpenChange={resetCtrl.onOpenChange}
|
||||
integrationLabel="review watch"
|
||||
previewLoader={resetCtrl.previewLoader}
|
||||
onConfirm={resetCtrl.confirmReset}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -301,12 +405,25 @@ function IssueWatchSection() {
|
||||
const { toast } = useToast();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingWatch, setEditingIssueWatch] = useState<IssueWatch | null>(null);
|
||||
const resetCtrl = useWatchResetController<IssueWatch>({
|
||||
preview: (w) => issueActions.previewReset(w.id, w.workspace_id),
|
||||
reset: (w) => issueActions.handleReset(w.id, w.workspace_id),
|
||||
});
|
||||
|
||||
const handleEdit = useCallback((watch: IssueWatch) => {
|
||||
setEditingIssueWatch(watch);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const { setResetting: setIssueResetting } = resetCtrl;
|
||||
const onResetClick = useCallback(
|
||||
(id: string) => {
|
||||
const w = issueActions.watches.find((item) => item.id === id);
|
||||
if (w) setIssueResetting(w);
|
||||
},
|
||||
[issueActions.watches, setIssueResetting],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
@@ -337,6 +454,7 @@ function IssueWatchSection() {
|
||||
onEdit={handleEdit}
|
||||
onDelete={issueActions.handleDelete}
|
||||
onTrigger={issueActions.handleTrigger}
|
||||
onReset={onResetClick}
|
||||
onToggleEnabled={issueActions.handleToggleEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -355,6 +473,15 @@ function IssueWatchSection() {
|
||||
toast({ description: "Issue watch updated", variant: "success" });
|
||||
}}
|
||||
/>
|
||||
{resetCtrl.resetting && (
|
||||
<ResetWatchDialog
|
||||
open
|
||||
onOpenChange={resetCtrl.onOpenChange}
|
||||
integrationLabel="issue watch"
|
||||
previewLoader={resetCtrl.previewLoader}
|
||||
onConfirm={resetCtrl.confirmReset}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react";
|
||||
import {
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconRestore,
|
||||
} 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";
|
||||
@@ -17,6 +23,7 @@ type IssueWatchTableProps = {
|
||||
onEdit: (watch: IssueWatch) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onTrigger: (id: string) => void;
|
||||
onReset: (id: string) => void;
|
||||
onToggleEnabled: (watch: IssueWatch) => void;
|
||||
};
|
||||
|
||||
@@ -47,10 +54,11 @@ type WatchActionsProps = {
|
||||
watch: IssueWatch;
|
||||
onToggleEnabled: (watch: IssueWatch) => void;
|
||||
onTrigger: (id: string) => void;
|
||||
onReset: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
};
|
||||
|
||||
function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActionsProps) {
|
||||
function WatchActions({ watch, onToggleEnabled, onTrigger, onReset, onDelete }: WatchActionsProps) {
|
||||
const { toast } = useToast();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
@@ -91,6 +99,24 @@ function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActi
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Check now</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 cursor-pointer"
|
||||
data-testid="watch-reset-button"
|
||||
aria-label="Reset watch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReset(watch.id);
|
||||
}}
|
||||
>
|
||||
<IconRestore className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -117,6 +143,7 @@ export function IssueWatchTable({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTrigger,
|
||||
onReset,
|
||||
onToggleEnabled,
|
||||
}: IssueWatchTableProps) {
|
||||
const workspaces = useAppStore((s) => s.workspaces.items);
|
||||
@@ -171,6 +198,7 @@ export function IssueWatchTable({
|
||||
watch={watch}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onTrigger={onTrigger}
|
||||
onReset={onReset}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react";
|
||||
import {
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconRestore,
|
||||
} 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";
|
||||
@@ -17,6 +23,7 @@ type ReviewWatchTableProps = {
|
||||
onEdit: (watch: ReviewWatch) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onTrigger: (id: string) => void;
|
||||
onReset: (id: string) => void;
|
||||
onToggleEnabled: (watch: ReviewWatch) => void;
|
||||
};
|
||||
|
||||
@@ -42,10 +49,11 @@ type WatchActionsProps = {
|
||||
watch: ReviewWatch;
|
||||
onToggleEnabled: (watch: ReviewWatch) => void;
|
||||
onTrigger: (id: string) => void;
|
||||
onReset: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
};
|
||||
|
||||
function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActionsProps) {
|
||||
function WatchActions({ watch, onToggleEnabled, onTrigger, onReset, onDelete }: WatchActionsProps) {
|
||||
const { toast } = useToast();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
@@ -86,6 +94,24 @@ function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActi
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Check now</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 cursor-pointer"
|
||||
data-testid="watch-reset-button"
|
||||
aria-label="Reset watch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReset(watch.id);
|
||||
}}
|
||||
>
|
||||
<IconRestore className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -112,6 +138,7 @@ export function ReviewWatchTable({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTrigger,
|
||||
onReset,
|
||||
onToggleEnabled,
|
||||
}: ReviewWatchTableProps) {
|
||||
const workspaces = useAppStore((s) => s.workspaces.items);
|
||||
@@ -162,6 +189,7 @@ export function ReviewWatchTable({
|
||||
watch={watch}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onTrigger={onTrigger}
|
||||
onReset={onReset}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react";
|
||||
import {
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconRestore,
|
||||
} 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";
|
||||
@@ -17,6 +23,7 @@ type JiraIssueWatchTableProps = {
|
||||
onEdit: (watch: JiraIssueWatch) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onTrigger: (id: string) => void;
|
||||
onReset: (id: string) => void;
|
||||
onToggleEnabled: (watch: JiraIssueWatch) => void;
|
||||
};
|
||||
|
||||
@@ -35,11 +42,13 @@ function WatchActions({
|
||||
watch,
|
||||
onToggleEnabled,
|
||||
onTrigger,
|
||||
onReset,
|
||||
onDelete,
|
||||
}: {
|
||||
watch: JiraIssueWatch;
|
||||
onToggleEnabled: (watch: JiraIssueWatch) => void;
|
||||
onTrigger: (id: string) => void;
|
||||
onReset: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -82,6 +91,24 @@ function WatchActions({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Check now</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 cursor-pointer"
|
||||
data-testid="watch-reset-button"
|
||||
aria-label="Reset watch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReset(watch.id);
|
||||
}}
|
||||
>
|
||||
<IconRestore className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -108,6 +135,7 @@ export function JiraIssueWatchTable({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTrigger,
|
||||
onReset,
|
||||
onToggleEnabled,
|
||||
}: JiraIssueWatchTableProps) {
|
||||
const workspaces = useAppStore((s) => s.workspaces.items);
|
||||
@@ -160,6 +188,7 @@ export function JiraIssueWatchTable({
|
||||
watch={watch}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onTrigger={onTrigger}
|
||||
onReset={onReset}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { ResetWatchDialog, useWatchResetController } from "@/components/watches/reset-watch-dialog";
|
||||
import { JiraIssueWatchTable } from "./jira-issue-watch-table";
|
||||
import { JiraIssueWatchDialog } from "./jira-issue-watch-dialog";
|
||||
import type { JiraIssueWatch } from "@/lib/types/jira";
|
||||
@@ -20,12 +21,13 @@ type RawActions = {
|
||||
update: ReturnType<typeof useJiraIssueWatches>["update"];
|
||||
remove: ReturnType<typeof useJiraIssueWatches>["remove"];
|
||||
trigger: ReturnType<typeof useJiraIssueWatches>["trigger"];
|
||||
reset: ReturnType<typeof useJiraIssueWatches>["reset"];
|
||||
};
|
||||
|
||||
// useToastedActions wraps the raw create/update/delete/trigger callbacks with
|
||||
// success/failure toasts. Per-row mutations need each watch's own workspaceId,
|
||||
// so the wrappers take the watch (not just an id) and pass it through.
|
||||
function useToastedActions({ create, update, remove, trigger }: RawActions) {
|
||||
function useToastedActions({ create, update, remove, trigger, reset }: RawActions) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const wrappedCreate = useCallback(
|
||||
@@ -95,22 +97,52 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) {
|
||||
[update, toast],
|
||||
);
|
||||
|
||||
const wrappedReset = useCallback(
|
||||
async (w: JiraIssueWatch) => {
|
||||
try {
|
||||
const res = await reset(w.id, w.workspaceId);
|
||||
const n = res?.tasksDeleted ?? 0;
|
||||
toast({
|
||||
description:
|
||||
n > 0
|
||||
? `Reset complete — deleted ${n} task(s); next poll will re-import matches.`
|
||||
: "Reset complete — next poll will re-import matches.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({ description: `Reset failed: ${String(err)}`, variant: "error" });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[reset, toast],
|
||||
);
|
||||
|
||||
return {
|
||||
create: wrappedCreate,
|
||||
update: wrappedUpdate,
|
||||
remove: wrappedDelete,
|
||||
trigger: wrappedTrigger,
|
||||
reset: wrappedReset,
|
||||
toggleEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function JiraIssueWatchersSection() {
|
||||
// Pass undefined to fetch every watch across every workspace.
|
||||
const { items, loading, create, update, remove, trigger } = useJiraIssueWatches();
|
||||
const actions = useToastedActions({ create, update, remove, trigger });
|
||||
const { items, loading, create, update, remove, trigger, previewReset, reset } =
|
||||
useJiraIssueWatches();
|
||||
const actions = useToastedActions({ create, update, remove, trigger, reset });
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<JiraIssueWatch | null>(null);
|
||||
// resetCtrl owns the watch awaiting reset confirmation and the stable
|
||||
// preview/confirm callbacks for ResetWatchDialog. The whole row is kept
|
||||
// (not just the id) so workspaceId stays available even if the list
|
||||
// re-renders mid-dialog.
|
||||
const resetCtrl = useWatchResetController<JiraIssueWatch>({
|
||||
preview: (w) => previewReset(w.id, w.workspaceId),
|
||||
reset: (w) => actions.reset(w).then(() => undefined),
|
||||
});
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setEditing(null);
|
||||
@@ -138,6 +170,14 @@ export function JiraIssueWatchersSection() {
|
||||
},
|
||||
[items, actions],
|
||||
);
|
||||
const { setResetting } = resetCtrl;
|
||||
const handleReset = useCallback(
|
||||
(id: string) => {
|
||||
const w = items.find((item) => item.id === id);
|
||||
if (w) setResetting(w);
|
||||
},
|
||||
[items, setResetting],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -162,6 +202,7 @@ export function JiraIssueWatchersSection() {
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onTrigger={handleTrigger}
|
||||
onReset={handleReset}
|
||||
onToggleEnabled={actions.toggleEnabled}
|
||||
/>
|
||||
)}
|
||||
@@ -180,6 +221,15 @@ export function JiraIssueWatchersSection() {
|
||||
return actions.update(id, req, w.workspaceId);
|
||||
}}
|
||||
/>
|
||||
{resetCtrl.resetting && (
|
||||
<ResetWatchDialog
|
||||
open
|
||||
onOpenChange={resetCtrl.onOpenChange}
|
||||
integrationLabel="JIRA watcher"
|
||||
previewLoader={resetCtrl.previewLoader}
|
||||
onConfirm={resetCtrl.confirmReset}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react";
|
||||
import {
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconRestore,
|
||||
} 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";
|
||||
@@ -16,6 +22,7 @@ type LinearIssueWatchTableProps = {
|
||||
onEdit: (watch: LinearIssueWatch) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onTrigger: (id: string) => void;
|
||||
onReset: (id: string) => void;
|
||||
onToggleEnabled: (watch: LinearIssueWatch) => void;
|
||||
};
|
||||
|
||||
@@ -50,11 +57,13 @@ function WatchActions({
|
||||
watch,
|
||||
onToggleEnabled,
|
||||
onTrigger,
|
||||
onReset,
|
||||
onDelete,
|
||||
}: {
|
||||
watch: LinearIssueWatch;
|
||||
onToggleEnabled: (watch: LinearIssueWatch) => void;
|
||||
onTrigger: (id: string) => void;
|
||||
onReset: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -95,6 +104,24 @@ function WatchActions({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Check now</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 cursor-pointer"
|
||||
data-testid="watch-reset-button"
|
||||
aria-label="Reset watch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReset(watch.id);
|
||||
}}
|
||||
>
|
||||
<IconRestore className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -121,6 +148,7 @@ export function LinearIssueWatchTable({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTrigger,
|
||||
onReset,
|
||||
onToggleEnabled,
|
||||
}: LinearIssueWatchTableProps) {
|
||||
const workspaces = useAppStore((s) => s.workspaces.items);
|
||||
@@ -176,6 +204,7 @@ export function LinearIssueWatchTable({
|
||||
watch={watch}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onTrigger={onTrigger}
|
||||
onReset={onReset}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { SettingsSection } from "@/components/settings/settings-section";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useLinearIssueWatches } from "@/hooks/domains/linear/use-linear-issue-watches";
|
||||
import { ResetWatchDialog, useWatchResetController } from "@/components/watches/reset-watch-dialog";
|
||||
import { LinearIssueWatchTable } from "./linear-issue-watch-table";
|
||||
import { LinearIssueWatchDialog } from "./linear-issue-watch-dialog";
|
||||
import type { LinearIssueWatch } from "@/lib/types/linear";
|
||||
@@ -20,9 +21,10 @@ type RawActions = {
|
||||
update: ReturnType<typeof useLinearIssueWatches>["update"];
|
||||
remove: ReturnType<typeof useLinearIssueWatches>["remove"];
|
||||
trigger: ReturnType<typeof useLinearIssueWatches>["trigger"];
|
||||
reset: ReturnType<typeof useLinearIssueWatches>["reset"];
|
||||
};
|
||||
|
||||
function useToastedActions({ create, update, remove, trigger }: RawActions) {
|
||||
function useToastedActions({ create, update, remove, trigger, reset }: RawActions) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const wrappedCreate = useCallback(
|
||||
@@ -90,21 +92,47 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) {
|
||||
[update, toast],
|
||||
);
|
||||
|
||||
const wrappedReset = useCallback(
|
||||
async (w: LinearIssueWatch) => {
|
||||
try {
|
||||
const res = await reset(w.id, w.workspaceId);
|
||||
const n = res?.tasksDeleted ?? 0;
|
||||
toast({
|
||||
description:
|
||||
n > 0
|
||||
? `Reset complete — deleted ${n} task(s); next poll will re-import matches.`
|
||||
: "Reset complete — next poll will re-import matches.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({ description: `Reset failed: ${String(err)}`, variant: "error" });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[reset, toast],
|
||||
);
|
||||
|
||||
return {
|
||||
create: wrappedCreate,
|
||||
update: wrappedUpdate,
|
||||
remove: wrappedDelete,
|
||||
trigger: wrappedTrigger,
|
||||
reset: wrappedReset,
|
||||
toggleEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function LinearIssueWatchersSection() {
|
||||
const { items, loading, create, update, remove, trigger } = useLinearIssueWatches();
|
||||
const actions = useToastedActions({ create, update, remove, trigger });
|
||||
const { items, loading, create, update, remove, trigger, previewReset, reset } =
|
||||
useLinearIssueWatches();
|
||||
const actions = useToastedActions({ create, update, remove, trigger, reset });
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LinearIssueWatch | null>(null);
|
||||
const resetCtrl = useWatchResetController<LinearIssueWatch>({
|
||||
preview: (w) => previewReset(w.id, w.workspaceId),
|
||||
reset: (w) => actions.reset(w).then(() => undefined),
|
||||
});
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setEditing(null);
|
||||
@@ -132,6 +160,14 @@ export function LinearIssueWatchersSection() {
|
||||
},
|
||||
[items, actions],
|
||||
);
|
||||
const { setResetting } = resetCtrl;
|
||||
const handleReset = useCallback(
|
||||
(id: string) => {
|
||||
const w = items.find((item) => item.id === id);
|
||||
if (w) setResetting(w);
|
||||
},
|
||||
[items, setResetting],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -156,6 +192,7 @@ export function LinearIssueWatchersSection() {
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onTrigger={handleTrigger}
|
||||
onReset={handleReset}
|
||||
onToggleEnabled={actions.toggleEnabled}
|
||||
/>
|
||||
)}
|
||||
@@ -172,6 +209,15 @@ export function LinearIssueWatchersSection() {
|
||||
return actions.update(id, req, w.workspaceId);
|
||||
}}
|
||||
/>
|
||||
{resetCtrl.resetting && (
|
||||
<ResetWatchDialog
|
||||
open
|
||||
onOpenChange={resetCtrl.onOpenChange}
|
||||
integrationLabel="Linear watcher"
|
||||
previewLoader={resetCtrl.previewLoader}
|
||||
onConfirm={resetCtrl.confirmReset}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,9 +146,16 @@ function buildAllFiles(
|
||||
prDiffFiles?: PRDiffFile[],
|
||||
): ReviewFile[] {
|
||||
const fileMap = new Map<string, ReviewFile>();
|
||||
if (prDiffFiles) addPRFiles(fileMap, prDiffFiles);
|
||||
if (cumulativeDiff?.files) addCumulativeDiffFiles(fileMap, cumulativeDiff.files, gitStatusFiles);
|
||||
// Order matters and must match `buildReviewSources` (the Changes panel
|
||||
// builder): uncommitted FIRST so its always-fresh WS-pushed diff content
|
||||
// wins over the polled cumulative diff for files that exist in both. Before
|
||||
// this, the dialog and the panel disagreed on `datasource.ts`-style files
|
||||
// (panel: fresh worktree content from `git-status`, dialog: stale cumulative
|
||||
// diff snapshot from the last fetch) — the dialog appeared to show outdated
|
||||
// content even though the cumulative-diff hook was successfully refetching.
|
||||
if (gitStatusFiles) addUncommittedFiles(fileMap, gitStatusFiles);
|
||||
if (cumulativeDiff?.files) addCumulativeDiffFiles(fileMap, cumulativeDiff.files, gitStatusFiles);
|
||||
if (prDiffFiles) addPRFiles(fileMap, prDiffFiles);
|
||||
return Array.from(fileMap.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react";
|
||||
import {
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconRestore,
|
||||
} 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";
|
||||
@@ -16,6 +22,7 @@ type SentryIssueWatchTableProps = {
|
||||
onEdit: (watch: SentryIssueWatch) => void;
|
||||
onDelete: (id: string, workspaceId: string) => void;
|
||||
onTrigger: (id: string, workspaceId: string) => void;
|
||||
onReset: (id: string, workspaceId: string) => void;
|
||||
onToggleEnabled: (watch: SentryIssueWatch) => void;
|
||||
};
|
||||
|
||||
@@ -55,11 +62,13 @@ function WatchActions({
|
||||
watch,
|
||||
onToggleEnabled,
|
||||
onTrigger,
|
||||
onReset,
|
||||
onDelete,
|
||||
}: {
|
||||
watch: SentryIssueWatch;
|
||||
onToggleEnabled: (watch: SentryIssueWatch) => void;
|
||||
onTrigger: (id: string, workspaceId: string) => void;
|
||||
onReset: (id: string, workspaceId: string) => void;
|
||||
onDelete: (id: string, workspaceId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -100,6 +109,24 @@ function WatchActions({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Check now</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 cursor-pointer"
|
||||
data-testid="watch-reset-button"
|
||||
aria-label="Reset watch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReset(watch.id, watch.workspaceId);
|
||||
}}
|
||||
>
|
||||
<IconRestore className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -126,6 +153,7 @@ export function SentryIssueWatchTable({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTrigger,
|
||||
onReset,
|
||||
onToggleEnabled,
|
||||
}: SentryIssueWatchTableProps) {
|
||||
const workspaces = useAppStore((s) => s.workspaces.items);
|
||||
@@ -181,6 +209,7 @@ export function SentryIssueWatchTable({
|
||||
watch={watch}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onTrigger={onTrigger}
|
||||
onReset={onReset}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { SettingsSection } from "@/components/settings/settings-section";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useSentryIssueWatches } from "@/hooks/domains/sentry/use-sentry-issue-watches";
|
||||
import { ResetWatchDialog, useWatchResetController } from "@/components/watches/reset-watch-dialog";
|
||||
import { SentryIssueWatchTable } from "./sentry-issue-watch-table";
|
||||
import { SentryIssueWatchDialog } from "./sentry-issue-watch-dialog";
|
||||
import type { SentryIssueWatch } from "@/lib/types/sentry";
|
||||
@@ -19,9 +20,10 @@ type RawActions = {
|
||||
update: ReturnType<typeof useSentryIssueWatches>["update"];
|
||||
remove: ReturnType<typeof useSentryIssueWatches>["remove"];
|
||||
trigger: ReturnType<typeof useSentryIssueWatches>["trigger"];
|
||||
reset: ReturnType<typeof useSentryIssueWatches>["reset"];
|
||||
};
|
||||
|
||||
function useToastedActions({ create, update, remove, trigger }: RawActions) {
|
||||
function useToastedActions({ create, update, remove, trigger, reset }: RawActions) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const wrappedCreate = useCallback(
|
||||
@@ -89,21 +91,47 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) {
|
||||
[update, toast],
|
||||
);
|
||||
|
||||
const wrappedReset = useCallback(
|
||||
async (id: string, workspaceId: string) => {
|
||||
try {
|
||||
const res = await reset(id, workspaceId);
|
||||
const n = res?.tasksDeleted ?? 0;
|
||||
toast({
|
||||
description:
|
||||
n > 0
|
||||
? `Reset complete — deleted ${n} task(s); next poll will re-import matches.`
|
||||
: "Reset complete — next poll will re-import matches.",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({ description: `Reset failed: ${String(err)}`, variant: "error" });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[reset, toast],
|
||||
);
|
||||
|
||||
return {
|
||||
create: wrappedCreate,
|
||||
update: wrappedUpdate,
|
||||
remove: wrappedDelete,
|
||||
trigger: wrappedTrigger,
|
||||
reset: wrappedReset,
|
||||
toggleEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function SentryIssueWatchersSection() {
|
||||
const { items, loading, create, update, remove, trigger } = useSentryIssueWatches();
|
||||
const actions = useToastedActions({ create, update, remove, trigger });
|
||||
const { items, loading, create, update, remove, trigger, previewReset, reset } =
|
||||
useSentryIssueWatches();
|
||||
const actions = useToastedActions({ create, update, remove, trigger, reset });
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SentryIssueWatch | null>(null);
|
||||
const resetCtrl = useWatchResetController<SentryIssueWatch>({
|
||||
preview: (w) => previewReset(w.id, w.workspaceId),
|
||||
reset: (w) => actions.reset(w.id, w.workspaceId).then(() => undefined),
|
||||
});
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setEditing(null);
|
||||
@@ -114,6 +142,15 @@ export function SentryIssueWatchersSection() {
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const { setResetting } = resetCtrl;
|
||||
const handleReset = useCallback(
|
||||
(id: string, _workspaceId: string) => {
|
||||
const w = items.find((item) => item.id === id);
|
||||
if (w) setResetting(w);
|
||||
},
|
||||
[items, setResetting],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={<IconBellRinging className="h-5 w-5" />}
|
||||
@@ -137,6 +174,7 @@ export function SentryIssueWatchersSection() {
|
||||
onEdit={openEdit}
|
||||
onDelete={actions.remove}
|
||||
onTrigger={actions.trigger}
|
||||
onReset={handleReset}
|
||||
onToggleEnabled={actions.toggleEnabled}
|
||||
/>
|
||||
)}
|
||||
@@ -149,6 +187,15 @@ export function SentryIssueWatchersSection() {
|
||||
onCreate={actions.create}
|
||||
onUpdate={actions.update}
|
||||
/>
|
||||
{resetCtrl.resetting && (
|
||||
<ResetWatchDialog
|
||||
open
|
||||
onOpenChange={resetCtrl.onOpenChange}
|
||||
integrationLabel="Sentry watcher"
|
||||
previewLoader={resetCtrl.previewLoader}
|
||||
onConfirm={resetCtrl.confirmReset}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@kandev/ui/alert-dialog";
|
||||
|
||||
// previewLoader fetches the count of tasks that would be deleted. The
|
||||
// dialog calls it once when it opens; the result is cached for the
|
||||
// remainder of the open session so the user doesn't see the number jump.
|
||||
type PreviewLoader = () => Promise<{ taskCount: number }>;
|
||||
|
||||
// onConfirm executes the destructive reset. Errors are surfaced via the
|
||||
// parent's toast — the dialog just stays open until onConfirm resolves so
|
||||
// the user can't double-fire it.
|
||||
type ConfirmHandler = () => Promise<void>;
|
||||
|
||||
type ResetWatchDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
// integrationLabel is shown in the title (e.g. "Jira watcher").
|
||||
integrationLabel: string;
|
||||
previewLoader: PreviewLoader;
|
||||
onConfirm: ConfirmHandler;
|
||||
};
|
||||
|
||||
// ResetWatchDialog is the shared confirmation dialog for the destructive
|
||||
// "Reset" action on every integration's watch row. It:
|
||||
// - Loads the preview count on open so the body reads "delete N task(s)"
|
||||
// - Falls back to a generic description if the preview fails
|
||||
// - Disables the confirm button while either the preview or the reset
|
||||
// is in flight, so double-clicks can't fire two resets
|
||||
//
|
||||
// All integrations (GitHub issue/review, Jira, Linear, Sentry) reuse this
|
||||
// component to keep the confirmation copy and behaviour consistent.
|
||||
export function ResetWatchDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
integrationLabel,
|
||||
previewLoader,
|
||||
onConfirm,
|
||||
}: ResetWatchDialogProps) {
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Reset state between opens so a previous error or count doesn't
|
||||
// bleed into the next confirmation.
|
||||
setCount(null);
|
||||
setPreviewError(false);
|
||||
setPreviewLoading(false);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
let ignore = false;
|
||||
setPreviewLoading(true);
|
||||
previewLoader()
|
||||
.then((res) => {
|
||||
if (ignore) return;
|
||||
setCount(res.taskCount);
|
||||
setPreviewError(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (ignore) return;
|
||||
setPreviewError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ignore) setPreviewLoading(false);
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [open, previewLoader]);
|
||||
|
||||
const description = renderDescription({ previewLoading, previewError, count });
|
||||
const confirmDisabled = previewLoading || confirming;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent data-testid="reset-watch-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset {integrationLabel}?</AlertDialogTitle>
|
||||
<AlertDialogDescription data-testid="reset-watch-dialog-description">
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="cursor-pointer" disabled={confirming}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="reset-watch-dialog-confirm"
|
||||
disabled={confirmDisabled}
|
||||
onClick={async (e) => {
|
||||
// Prevent the AlertDialog from auto-closing before onConfirm
|
||||
// resolves — the parent closes the dialog itself after the
|
||||
// toast fires so an error keeps the dialog open.
|
||||
e.preventDefault();
|
||||
setConfirming(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{confirming ? "Resetting…" : "Reset"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// useWatchResetController encapsulates the reset confirmation flow shared
|
||||
// by every integration's settings section:
|
||||
// - tracks which watch is awaiting confirmation
|
||||
// - exposes a stable previewLoader / confirmReset pair for ResetWatchDialog
|
||||
// - closes the dialog on success and keeps it open on failure so the user
|
||||
// can retry after seeing the parent's toast
|
||||
//
|
||||
// Caller supplies preview(watch) and reset(watch). resetting carries the
|
||||
// whole watch row so per-row workspaceId stays available even if the
|
||||
// underlying list re-renders mid-dialog.
|
||||
//
|
||||
// opts is captured by ref, NOT included in the callbacks' deps. Call sites
|
||||
// pass `useWatchResetController({ preview: ..., reset: ... })` with an
|
||||
// inline object literal, so opts is a fresh reference on every parent
|
||||
// render. Including it in the deps would re-create previewLoader each
|
||||
// render, re-firing ResetWatchDialog's `useEffect([open, previewLoader])`
|
||||
// and re-issuing the preview API call mid-dialog. The ref keeps the
|
||||
// closure stable while still seeing the latest callbacks.
|
||||
export function useWatchResetController<TWatch extends { id: string }>(opts: {
|
||||
preview: (watch: TWatch) => Promise<{ taskCount: number }>;
|
||||
reset: (watch: TWatch) => Promise<void>;
|
||||
}) {
|
||||
const [resetting, setResetting] = useState<TWatch | null>(null);
|
||||
const optsRef = useRef(opts);
|
||||
// Sync the ref in a layout effect rather than during render: a render-
|
||||
// time write trips react-x/no-access-state-in-setstate (React 19 docs)
|
||||
// as a stale-closure footgun, and a regular useEffect would let the
|
||||
// confirm-click handler (which fires synchronously after a commit) read
|
||||
// a previous render's reset() callback. useLayoutEffect runs before the
|
||||
// browser paints, so optsRef.current is always current for the next
|
||||
// user interaction.
|
||||
useLayoutEffect(() => {
|
||||
optsRef.current = opts;
|
||||
}, [opts]);
|
||||
|
||||
const previewLoader = useCallback(() => {
|
||||
if (!resetting) {
|
||||
// useEffect inside ResetWatchDialog only runs when open=true, which
|
||||
// only happens when resetting != null, so this branch is unreachable
|
||||
// in practice. The check keeps the closure typed cleanly.
|
||||
return Promise.resolve({ taskCount: 0 });
|
||||
}
|
||||
return optsRef.current.preview(resetting);
|
||||
}, [resetting]);
|
||||
|
||||
const confirmReset = useCallback(async () => {
|
||||
if (!resetting) return;
|
||||
try {
|
||||
await optsRef.current.reset(resetting);
|
||||
setResetting(null);
|
||||
} catch {
|
||||
// Keep dialog open on error so the user sees the toast and can retry.
|
||||
}
|
||||
}, [resetting]);
|
||||
|
||||
const onOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) setResetting(null);
|
||||
}, []);
|
||||
|
||||
return { resetting, setResetting, previewLoader, confirmReset, onOpenChange };
|
||||
}
|
||||
|
||||
function renderDescription({
|
||||
previewLoading,
|
||||
previewError,
|
||||
count,
|
||||
}: {
|
||||
previewLoading: boolean;
|
||||
previewError: boolean;
|
||||
count: number | null;
|
||||
}): string {
|
||||
const tail =
|
||||
" The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.";
|
||||
if (previewLoading) return "Checking how many tasks would be deleted…";
|
||||
if (previewError) {
|
||||
return (
|
||||
"This will delete every task previously created by the watch, including archived tasks." +
|
||||
tail
|
||||
);
|
||||
}
|
||||
if (count === 0) {
|
||||
return (
|
||||
"No tasks were created by this watch yet — only the polling state will be cleared." + tail
|
||||
);
|
||||
}
|
||||
const plural = count === 1 ? "task" : "tasks";
|
||||
return `This will delete ${count} ${plural} previously created by the watch, including archived ${plural}.${tail}`;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
/**
|
||||
* Covers the Reset action on a GitHub review watch. The flow:
|
||||
* 1. Watch creates a task for a mock PR (via trigger).
|
||||
* 2. User clicks Reset on the settings page → confirmation dialog shows
|
||||
* the preview count ("delete N task(s)").
|
||||
* 3. Confirm → backend cascade-deletes the task, wipes dedup, nulls
|
||||
* last_polled_at; next trigger re-imports the same PR.
|
||||
*
|
||||
* The same controller wiring backs Jira / Linear / Sentry / GitHub-issue
|
||||
* watches (`watchreset.Run`), so this single spec exercises the shared
|
||||
* orchestration end-to-end. Per-integration store coverage lives in the
|
||||
* Go unit tests.
|
||||
*/
|
||||
test.describe("GitHub review watch reset", () => {
|
||||
test("preview + reset endpoints delete tasks and clear polling cursor", async ({
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.mockGitHubSetUser("test-user");
|
||||
await apiClient.mockGitHubAddPRs([
|
||||
{
|
||||
number: 5101,
|
||||
title: "Reset me",
|
||||
state: "open",
|
||||
head_branch: "feature/reset-me",
|
||||
base_branch: "main",
|
||||
author_login: "contributor",
|
||||
repo_owner: "testorg",
|
||||
repo_name: "testrepo",
|
||||
requested_reviewers: [{ login: "test-user", type: "User" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const inboxStep = await apiClient.createWorkflowStep(seedData.workflowId, "Reset Inbox", 0);
|
||||
const watch = await apiClient.createReviewWatch(
|
||||
seedData.workspaceId,
|
||||
seedData.workflowId,
|
||||
inboxStep.id,
|
||||
seedData.agentProfileId,
|
||||
{ repos: [{ owner: "testorg", name: "testrepo" }] },
|
||||
);
|
||||
|
||||
await apiClient.triggerReviewWatch(watch.id);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
|
||||
return tasks.find((t) => t.title.includes("PR #5101"));
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
// Preview reports the count the dialog will surface. workspace_id is
|
||||
// required — the reset endpoints reject cross-workspace IDs with 404.
|
||||
const wsQuery = `workspace_id=${seedData.workspaceId}`;
|
||||
const preview = await apiClient.rawRequest(
|
||||
"GET",
|
||||
`/api/v1/github/watches/review/${watch.id}/reset/preview?${wsQuery}`,
|
||||
);
|
||||
expect(preview.status).toBe(200);
|
||||
expect(await preview.json()).toMatchObject({ taskCount: 1 });
|
||||
|
||||
// Reset cascades the delete and returns the count it actually removed.
|
||||
const reset = await apiClient.rawRequest(
|
||||
"POST",
|
||||
`/api/v1/github/watches/review/${watch.id}/reset?${wsQuery}`,
|
||||
);
|
||||
expect(reset.status).toBe(200);
|
||||
expect(await reset.json()).toMatchObject({ tasksDeleted: 1 });
|
||||
|
||||
// Task is gone from the workspace.
|
||||
const { tasks: afterReset } = await apiClient.listTasks(seedData.workspaceId);
|
||||
expect(afterReset.find((t) => t.title.includes("PR #5101"))).toBeUndefined();
|
||||
|
||||
// Watch's polling cursor was cleared so the next poll cycle will
|
||||
// re-evaluate every currently-matching PR. last_polled_at sits on
|
||||
// the watch row exposed by the list endpoint.
|
||||
const listRes = await apiClient.rawRequest(
|
||||
"GET",
|
||||
`/api/v1/github/watches/review?workspace_id=${seedData.workspaceId}`,
|
||||
);
|
||||
expect(listRes.status).toBe(200);
|
||||
const list = (await listRes.json()) as {
|
||||
watches: Array<{ id: string; last_polled_at: string | null }>;
|
||||
};
|
||||
const refreshed = list.watches.find((w) => w.id === watch.id);
|
||||
expect(refreshed).toBeDefined();
|
||||
// The Go model marshals *time.Time with omitempty, so a nil cursor is
|
||||
// serialised as either null or omitted — both are valid "cleared" shapes.
|
||||
expect(refreshed!.last_polled_at ?? null).toBeNull();
|
||||
|
||||
// CheckReviewWatch now reports PR #5101 as new again — the dedup
|
||||
// wipe means the periodic poller will publish a NewReviewPR event for
|
||||
// it on the next tick. (The HTTP trigger handler intentionally only
|
||||
// probes; event publishing is the poller's job.)
|
||||
const trigger = await apiClient.triggerReviewWatch(watch.id);
|
||||
expect(trigger.new_prs).toBe(1);
|
||||
});
|
||||
|
||||
test("settings page reset flow shows preview count and deletes the task", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.mockGitHubSetUser("test-user");
|
||||
await apiClient.mockGitHubAddPRs([
|
||||
{
|
||||
number: 5201,
|
||||
title: "UI reset",
|
||||
state: "open",
|
||||
head_branch: "feature/ui-reset",
|
||||
base_branch: "main",
|
||||
author_login: "contributor",
|
||||
repo_owner: "testorg",
|
||||
repo_name: "testrepo",
|
||||
requested_reviewers: [{ login: "test-user", type: "User" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const inboxStep = await apiClient.createWorkflowStep(seedData.workflowId, "UI Reset Inbox", 0);
|
||||
const watch = await apiClient.createReviewWatch(
|
||||
seedData.workspaceId,
|
||||
seedData.workflowId,
|
||||
inboxStep.id,
|
||||
seedData.agentProfileId,
|
||||
{ repos: [{ owner: "testorg", name: "testrepo" }] },
|
||||
);
|
||||
|
||||
await apiClient.triggerReviewWatch(watch.id);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
|
||||
return tasks.find((t) => t.title.includes("PR #5201"));
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await testPage.goto("/settings/integrations/github");
|
||||
|
||||
// The settings page aggregates every workspace's watches into a flat
|
||||
// table — find the row for our PR by the repo name it shows, then click
|
||||
// its Reset button. Scoping the testid lookup to the row ensures the
|
||||
// test fails loudly if a future selector change clones the reset button
|
||||
// onto another row.
|
||||
const watchRow = testPage.getByRole("row", { name: /testorg\/testrepo/i });
|
||||
const resetButton = watchRow.getByTestId("watch-reset-button");
|
||||
await expect(resetButton).toBeVisible({ timeout: 10_000 });
|
||||
await resetButton.click();
|
||||
|
||||
// Dialog opens, preview loads, body shows "delete 1 task" wording.
|
||||
const dialog = testPage.getByTestId("reset-watch-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
const description = testPage.getByTestId("reset-watch-dialog-description");
|
||||
await expect(description).toContainText(/delete 1 task/i);
|
||||
|
||||
await testPage.getByTestId("reset-watch-dialog-confirm").click();
|
||||
|
||||
// Dialog closes on success.
|
||||
await expect(dialog).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
// Success toast surfaces the deletion count.
|
||||
await expect(testPage.getByText(/deleted 1 task/i)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The task was actually removed.
|
||||
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
|
||||
expect(tasks.find((t) => t.title.includes("PR #5201"))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
deleteIssueWatch,
|
||||
triggerIssueWatch,
|
||||
triggerAllIssueWatches,
|
||||
previewResetIssueWatch,
|
||||
resetIssueWatch,
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import type { CreateIssueWatchRequest, UpdateIssueWatchRequest } from "@/lib/types/github";
|
||||
|
||||
// useIssueWatches has three modes:
|
||||
@@ -25,6 +27,10 @@ export function useIssueWatches(workspaceId?: string | null) {
|
||||
const addWatch = useAppStore((state) => state.addIssueWatch);
|
||||
const updateWatch = useAppStore((state) => state.updateIssueWatch);
|
||||
const removeWatch = useAppStore((state) => state.removeIssueWatch);
|
||||
// storeApi exposes getState() without subscribing — used in reset() to
|
||||
// read the current watch row outside of the React render cycle so the
|
||||
// callback doesn't need `items` as a dependency.
|
||||
const storeApi = useAppStoreApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId === null || loaded || loading) return;
|
||||
@@ -76,6 +82,22 @@ export function useIssueWatches(workspaceId?: string | null) {
|
||||
return triggerAllIssueWatches(workspaceId);
|
||||
}, [workspaceId]);
|
||||
|
||||
const previewReset = useCallback(async (id: string, watchWorkspaceId: string) => {
|
||||
return previewResetIssueWatch(id, watchWorkspaceId);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(
|
||||
async (id: string, watchWorkspaceId: string) => {
|
||||
const res = await resetIssueWatch(id, watchWorkspaceId);
|
||||
// Patch the cached watch so the "Last polled" column reflects the
|
||||
// reset immediately without waiting for the next poll tick.
|
||||
const current = storeApi.getState().issueWatches.items.find((w) => w.id === id);
|
||||
if (current) updateWatch({ ...current, last_polled_at: null });
|
||||
return res;
|
||||
},
|
||||
[storeApi, updateWatch],
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
loaded,
|
||||
@@ -85,5 +107,7 @@ export function useIssueWatches(workspaceId?: string | null) {
|
||||
remove,
|
||||
trigger,
|
||||
triggerAll,
|
||||
previewReset,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { createDebugLogger } from "@/lib/debug/log";
|
||||
import type { PRDiffFile } from "@/lib/types/github";
|
||||
|
||||
const debug = createDebugLogger("review:pr-diff");
|
||||
|
||||
type PRDiffState = {
|
||||
files: PRDiffFile[];
|
||||
loading: boolean;
|
||||
@@ -26,19 +29,20 @@ async function fetchPRFiles(
|
||||
if (!client) return;
|
||||
|
||||
setState({ files: [], loading: true, error: null });
|
||||
debug("fetch.start", { owner, repo, prNumber });
|
||||
try {
|
||||
const response = await client.request<{ files?: PRDiffFile[] }>("github.pr_files.get", {
|
||||
owner,
|
||||
repo,
|
||||
number: prNumber,
|
||||
});
|
||||
setState({ files: response?.files ?? [], loading: false, error: null });
|
||||
const files = response?.files ?? [];
|
||||
setState({ files, loading: false, error: null });
|
||||
debug("fetch.success", { owner, repo, prNumber, fileCount: files.length });
|
||||
} catch (err) {
|
||||
setState({
|
||||
files: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Failed to fetch PR files",
|
||||
});
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch PR files";
|
||||
setState({ files: [], loading: false, error: message });
|
||||
debug("fetch.error", { owner, repo, prNumber, error: message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
deleteReviewWatch,
|
||||
triggerReviewWatch,
|
||||
triggerAllReviewWatches,
|
||||
previewResetReviewWatch,
|
||||
resetReviewWatch,
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import type { CreateReviewWatchRequest, UpdateReviewWatchRequest } from "@/lib/types/github";
|
||||
|
||||
// useReviewWatches has three modes:
|
||||
@@ -25,6 +27,10 @@ export function useReviewWatches(workspaceId?: string | null) {
|
||||
const addWatch = useAppStore((state) => state.addReviewWatch);
|
||||
const updateWatch = useAppStore((state) => state.updateReviewWatch);
|
||||
const removeWatch = useAppStore((state) => state.removeReviewWatch);
|
||||
// storeApi exposes getState() without subscribing — used in reset() to
|
||||
// read the current watch row outside of the React render cycle so the
|
||||
// callback doesn't need `items` as a dependency.
|
||||
const storeApi = useAppStoreApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId === null || loaded || loading) return;
|
||||
@@ -76,6 +82,22 @@ export function useReviewWatches(workspaceId?: string | null) {
|
||||
return triggerAllReviewWatches(workspaceId);
|
||||
}, [workspaceId]);
|
||||
|
||||
const previewReset = useCallback(async (id: string, watchWorkspaceId: string) => {
|
||||
return previewResetReviewWatch(id, watchWorkspaceId);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(
|
||||
async (id: string, watchWorkspaceId: string) => {
|
||||
const res = await resetReviewWatch(id, watchWorkspaceId);
|
||||
// Patch the cached watch so the "Last polled" column reflects the
|
||||
// reset immediately without waiting for the next poll tick.
|
||||
const current = storeApi.getState().reviewWatches.items.find((w) => w.id === id);
|
||||
if (current) updateWatch({ ...current, last_polled_at: null });
|
||||
return res;
|
||||
},
|
||||
[storeApi, updateWatch],
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
loaded,
|
||||
@@ -85,5 +107,7 @@ export function useReviewWatches(workspaceId?: string | null) {
|
||||
remove,
|
||||
trigger,
|
||||
triggerAll,
|
||||
previewReset,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,10 +7,17 @@ import {
|
||||
updateJiraIssueWatch,
|
||||
deleteJiraIssueWatch,
|
||||
triggerJiraIssueWatch,
|
||||
previewResetJiraIssueWatch,
|
||||
resetJiraIssueWatch,
|
||||
} from "@/lib/api/domains/jira-api";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { CreateJiraIssueWatchInput, UpdateJiraIssueWatchInput } from "@/lib/types/jira";
|
||||
|
||||
// WORKSPACE_REQUIRED is thrown by per-row mutation callbacks when the
|
||||
// install-wide listing case forgets to forward the row's workspaceId
|
||||
// (the install-wide hook leaves workspaceId undefined to fetch all rows).
|
||||
const WORKSPACE_REQUIRED = "workspaceId required";
|
||||
|
||||
/**
|
||||
* useJiraIssueWatches owns the JIRA-watcher list:
|
||||
* - workspaceId: string → fetch and operate on watches in one workspace
|
||||
@@ -72,7 +79,7 @@ export function useJiraIssueWatches(workspaceId?: string | null) {
|
||||
const update = useCallback(
|
||||
async (id: string, req: UpdateJiraIssueWatchInput, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error("workspaceId required");
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
const watch = await updateJiraIssueWatch(ws, id, req);
|
||||
updateWatch(watch);
|
||||
return watch;
|
||||
@@ -83,7 +90,7 @@ export function useJiraIssueWatches(workspaceId?: string | null) {
|
||||
const remove = useCallback(
|
||||
async (id: string, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error("workspaceId required");
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
await deleteJiraIssueWatch(ws, id);
|
||||
removeWatch(id);
|
||||
},
|
||||
@@ -93,11 +100,36 @@ export function useJiraIssueWatches(workspaceId?: string | null) {
|
||||
const trigger = useCallback(
|
||||
async (id: string, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error("workspaceId required");
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
return triggerJiraIssueWatch(ws, id);
|
||||
},
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
return { items, loaded, loading, create, update, remove, trigger };
|
||||
// previewReset / reset use the same per-row workspaceId pattern as update /
|
||||
// remove / trigger — the install-wide listing case relies on the caller
|
||||
// passing the row's stored workspaceId, satisfying the backend IDOR guard.
|
||||
const previewReset = useCallback(
|
||||
async (id: string, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
return previewResetJiraIssueWatch(ws, id);
|
||||
},
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
const reset = useCallback(
|
||||
async (id: string, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
const res = await resetJiraIssueWatch(ws, id);
|
||||
// Wipe the dedup row cache so the next list/refresh doesn't surface
|
||||
// stale state — the watch is now in "freshly created" mode.
|
||||
resetWatches();
|
||||
return res;
|
||||
},
|
||||
[workspaceId, resetWatches],
|
||||
);
|
||||
|
||||
return { items, loaded, loading, create, update, remove, trigger, previewReset, reset };
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ import {
|
||||
updateLinearIssueWatch,
|
||||
deleteLinearIssueWatch,
|
||||
triggerLinearIssueWatch,
|
||||
previewResetLinearIssueWatch,
|
||||
resetLinearIssueWatch,
|
||||
} from "@/lib/api/domains/linear-api";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { CreateLinearIssueWatchInput, UpdateLinearIssueWatchInput } from "@/lib/types/linear";
|
||||
|
||||
// WORKSPACE_REQUIRED is thrown by per-row mutation callbacks when the
|
||||
// install-wide listing case forgets to forward the row's workspaceId.
|
||||
const WORKSPACE_REQUIRED = "workspaceId required";
|
||||
|
||||
/**
|
||||
* useLinearIssueWatches owns the Linear-watcher list:
|
||||
* - workspaceId: string → fetch and operate on watches in one workspace
|
||||
@@ -69,7 +75,7 @@ export function useLinearIssueWatches(workspaceId?: string | null) {
|
||||
const update = useCallback(
|
||||
async (id: string, req: UpdateLinearIssueWatchInput, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error("workspaceId required");
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
const watch = await updateLinearIssueWatch(ws, id, req);
|
||||
updateWatch(watch);
|
||||
return watch;
|
||||
@@ -80,7 +86,7 @@ export function useLinearIssueWatches(workspaceId?: string | null) {
|
||||
const remove = useCallback(
|
||||
async (id: string, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error("workspaceId required");
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
await deleteLinearIssueWatch(ws, id);
|
||||
removeWatch(id);
|
||||
},
|
||||
@@ -90,11 +96,31 @@ export function useLinearIssueWatches(workspaceId?: string | null) {
|
||||
const trigger = useCallback(
|
||||
async (id: string, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error("workspaceId required");
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
return triggerLinearIssueWatch(ws, id);
|
||||
},
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
return { items, loaded, loading, create, update, remove, trigger };
|
||||
const previewReset = useCallback(
|
||||
async (id: string, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
return previewResetLinearIssueWatch(ws, id);
|
||||
},
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
const reset = useCallback(
|
||||
async (id: string, rowWorkspaceId?: string) => {
|
||||
const ws = rowWorkspaceId ?? workspaceId;
|
||||
if (!ws) throw new Error(WORKSPACE_REQUIRED);
|
||||
const res = await resetLinearIssueWatch(ws, id);
|
||||
resetWatches();
|
||||
return res;
|
||||
},
|
||||
[workspaceId, resetWatches],
|
||||
);
|
||||
|
||||
return { items, loaded, loading, create, update, remove, trigger, previewReset, reset };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
updateSentryIssueWatch,
|
||||
deleteSentryIssueWatch,
|
||||
triggerSentryIssueWatch,
|
||||
previewResetSentryIssueWatch,
|
||||
resetSentryIssueWatch,
|
||||
} from "@/lib/api/domains/sentry-api";
|
||||
import type {
|
||||
CreateSentryIssueWatchRequest,
|
||||
@@ -104,5 +106,17 @@ export function useSentryIssueWatches(workspaceId?: string | null) {
|
||||
return triggerSentryIssueWatch(id, watchWorkspaceId);
|
||||
}, []);
|
||||
|
||||
return { items, loaded, loading, create, update, remove, trigger };
|
||||
const previewReset = useCallback(async (id: string, watchWorkspaceId: string) => {
|
||||
return previewResetSentryIssueWatch(id, watchWorkspaceId);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(async (id: string, watchWorkspaceId: string) => {
|
||||
const res = await resetSentryIssueWatch(id, watchWorkspaceId);
|
||||
// Force the next render to refetch so the now-empty dedup set / cleared
|
||||
// last_polled_at land in the UI immediately.
|
||||
setLoaded(false);
|
||||
return res;
|
||||
}, []);
|
||||
|
||||
return { items, loaded, loading, create, update, remove, trigger, previewReset, reset };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { createDebugLogger, isDebug } from "@/lib/debug/log";
|
||||
import type { CumulativeDiff } from "@/lib/state/slices/session-runtime/types";
|
||||
|
||||
const debug = createDebugLogger("review:cumulative");
|
||||
|
||||
const cumulativeDiffCache: Record<string, CumulativeDiff | null> = {};
|
||||
const loadingState: Record<string, boolean> = {};
|
||||
// Invalidations that arrived while a fetch was in flight. The in-flight
|
||||
// request can't be guaranteed to capture the working-tree state it was
|
||||
// triggered by (git diff runs once on the server, edits may land after),
|
||||
// so we always run one follow-up fetch after the in-flight one settles.
|
||||
const pendingInvalidationByEnvKey: Record<string, boolean> = {};
|
||||
|
||||
const listeners = new Set<(envKey: string) => void>();
|
||||
// Listener events: "invalidated" triggers a refetch (bumps invalidationCount);
|
||||
// "populated" tells subscribers to pick up the fresh cache value WITHOUT
|
||||
// triggering another fetch. Without the populated signal, only the subscriber
|
||||
// that "wins" the fetch race calls setDiff — the others stay stale because
|
||||
// useState only reads the module cache at initial mount.
|
||||
type ListenerEvent = { envKey: string; kind: "invalidated" | "populated" };
|
||||
const listeners = new Set<(event: ListenerEvent) => void>();
|
||||
|
||||
/**
|
||||
* Invalidate the cumulative diff cache for the given environment key.
|
||||
@@ -14,7 +28,39 @@ const listeners = new Set<(envKey: string) => void>();
|
||||
*/
|
||||
export function invalidateCumulativeDiffCache(envKey: string) {
|
||||
delete cumulativeDiffCache[envKey];
|
||||
listeners.forEach((fn) => fn(envKey));
|
||||
// If a fetch is in flight, mark a pending refetch — the in-flight git diff
|
||||
// may have run before the worktree edit that triggered this invalidation,
|
||||
// so we need to refetch once it settles. Drained in fetchCumulativeDiff's
|
||||
// finally block. Only `invalidateCumulativeDiffCache` sets this flag (not
|
||||
// the in-flight skip path in the fetch itself), so duplicate React
|
||||
// subscribers don't create phantom invalidations that loop forever.
|
||||
if (loadingState[envKey]) {
|
||||
pendingInvalidationByEnvKey[envKey] = true;
|
||||
}
|
||||
debug("cache.invalidated", { envKey });
|
||||
listeners.forEach((fn) => fn({ envKey, kind: "invalidated" }));
|
||||
}
|
||||
|
||||
function commitFetchedDiff(
|
||||
envKey: string,
|
||||
sessionId: string,
|
||||
diff: CumulativeDiff | null,
|
||||
setDiff: (d: CumulativeDiff | null) => void,
|
||||
) {
|
||||
cumulativeDiffCache[envKey] = diff;
|
||||
setDiff(diff);
|
||||
if (isDebug()) {
|
||||
debug("fetch.success", {
|
||||
sessionId,
|
||||
envKey,
|
||||
fileCount: diff ? Object.keys(diff.files ?? {}).length : 0,
|
||||
empty: !diff,
|
||||
});
|
||||
}
|
||||
// Broadcast the populated cache to other subscribers so they pick up the
|
||||
// fresh value. Without this, only the subscriber that "won" the fetch race
|
||||
// calls setDiff — others stay stale on the value they had at mount time.
|
||||
listeners.forEach((fn) => fn({ envKey, kind: "populated" }));
|
||||
}
|
||||
|
||||
export function useCumulativeDiff(sessionId: string | null) {
|
||||
@@ -36,7 +82,15 @@ export function useCumulativeDiff(sessionId: string | null) {
|
||||
|
||||
const fetchCumulativeDiff = useCallback(async () => {
|
||||
if (!sessionId || !envKey) return;
|
||||
if (loadingState[envKey]) return;
|
||||
if (loadingState[envKey]) {
|
||||
// Another subscriber already kicked off the fetch. Do NOT set the
|
||||
// pending flag here — only `invalidateCumulativeDiffCache` does that,
|
||||
// so we don't loop on duplicate subscriber calls. When the in-flight
|
||||
// fetch completes it broadcasts a "populated" event that updates every
|
||||
// subscriber from the shared cache.
|
||||
debug("fetch.skip.in-flight", { sessionId, envKey });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
@@ -46,6 +100,7 @@ export function useCumulativeDiff(sessionId: string | null) {
|
||||
setLoading(true);
|
||||
loadingState[envKey] = true;
|
||||
setError(null);
|
||||
debug("fetch.start", { sessionId, envKey });
|
||||
|
||||
try {
|
||||
// Backend routes by session_id, but we cache by envKey
|
||||
@@ -57,19 +112,27 @@ export function useCumulativeDiff(sessionId: string | null) {
|
||||
// Discard if the environment changed while the request was in flight
|
||||
if (version !== requestVersionRef.current) return;
|
||||
|
||||
if (response?.cumulative_diff) {
|
||||
cumulativeDiffCache[envKey] = response.cumulative_diff;
|
||||
setDiff(response.cumulative_diff);
|
||||
}
|
||||
commitFetchedDiff(envKey, sessionId, response?.cumulative_diff ?? null, setDiff);
|
||||
} catch (err) {
|
||||
if (version !== requestVersionRef.current) return;
|
||||
console.error("Failed to fetch cumulative diff:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch cumulative diff");
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch cumulative diff";
|
||||
setError(message);
|
||||
debug("fetch.error", { sessionId, envKey, error: message });
|
||||
} finally {
|
||||
if (version === requestVersionRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
loadingState[envKey] = false;
|
||||
// Drain any invalidation that arrived mid-flight by re-notifying
|
||||
// listeners, which bumps `invalidationCount` and re-runs the fetch
|
||||
// effect. Cleared first so the follow-up fetch can record its own
|
||||
// pending state if needed.
|
||||
if (pendingInvalidationByEnvKey[envKey]) {
|
||||
delete pendingInvalidationByEnvKey[envKey];
|
||||
debug("fetch.drain.pending", { sessionId, envKey });
|
||||
listeners.forEach((fn) => fn({ envKey, kind: "invalidated" }));
|
||||
}
|
||||
}
|
||||
}, [sessionId, envKey]);
|
||||
|
||||
@@ -82,6 +145,7 @@ export function useCumulativeDiff(sessionId: string | null) {
|
||||
requestVersionRef.current++;
|
||||
if (envKey) {
|
||||
loadingState[envKey] = false;
|
||||
delete pendingInvalidationByEnvKey[envKey];
|
||||
setDiff(cumulativeDiffCache[envKey] ?? null);
|
||||
} else {
|
||||
setDiff(null);
|
||||
@@ -95,11 +159,16 @@ export function useCumulativeDiff(sessionId: string | null) {
|
||||
fetchCumulativeDiff();
|
||||
}, [envKey, invalidationCount, fetchCumulativeDiff]);
|
||||
|
||||
// Subscribe to cache invalidation from WS handlers
|
||||
// Subscribe to cache events from WS handlers and from other subscribers'
|
||||
// successful fetches.
|
||||
useEffect(() => {
|
||||
if (!envKey) return;
|
||||
const handler = (invalidatedEnvKey: string) => {
|
||||
if (invalidatedEnvKey === envKey) {
|
||||
const handler = (event: ListenerEvent) => {
|
||||
if (event.envKey !== envKey) return;
|
||||
if (event.kind === "populated") {
|
||||
// Pick up the freshly-cached value without triggering a refetch.
|
||||
setDiff(cumulativeDiffCache[envKey] ?? null);
|
||||
} else {
|
||||
setInvalidationCount((c) => c + 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSessionGitStatus, useSessionGitStatusByRepo } from "./use-session-git-status";
|
||||
import { useCumulativeDiff } from "./use-cumulative-diff";
|
||||
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
|
||||
import { usePRDiff } from "@/hooks/domains/github/use-pr-diff";
|
||||
import { normalizeDiffContent } from "@/components/review/types";
|
||||
import { createDebugLogger } from "@/lib/debug/log";
|
||||
import type { ReviewFile } from "@/components/review/types";
|
||||
import type { PRDiffFile } from "@/lib/types/github";
|
||||
|
||||
const debug = createDebugLogger("review:sources");
|
||||
|
||||
export type ReviewSource = "uncommitted" | "committed" | "pr";
|
||||
|
||||
export type SourceCounts = Record<ReviewSource, number>;
|
||||
@@ -266,6 +269,35 @@ export function useReviewSources(sessionId: string | null | undefined): UseRevie
|
||||
// the GitHub PR diff hydrates.
|
||||
const hasPR = !!pr;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
debug("merged", {
|
||||
sessionId,
|
||||
total: allFiles.length,
|
||||
uncommitted: sourceCounts.uncommitted,
|
||||
committed: sourceCounts.committed,
|
||||
pr: sourceCounts.pr,
|
||||
hasPR,
|
||||
hasCumulativeDiff: !!cumulativeDiff,
|
||||
cumulativeLoading,
|
||||
prDiffLoading,
|
||||
prRepo: pr?.repo ?? null,
|
||||
prNumber: pr?.pr_number ?? null,
|
||||
});
|
||||
}, [
|
||||
sessionId,
|
||||
allFiles.length,
|
||||
sourceCounts.uncommitted,
|
||||
sourceCounts.committed,
|
||||
sourceCounts.pr,
|
||||
hasPR,
|
||||
cumulativeDiff,
|
||||
cumulativeLoading,
|
||||
prDiffLoading,
|
||||
pr?.repo,
|
||||
pr?.pr_number,
|
||||
]);
|
||||
|
||||
return {
|
||||
allFiles,
|
||||
sourceCounts,
|
||||
|
||||
@@ -224,6 +224,40 @@ export async function triggerReviewWatch(id: string, options?: ApiRequestOptions
|
||||
});
|
||||
}
|
||||
|
||||
// previewResetReviewWatch returns how many tasks would be deleted if the
|
||||
// review watch were reset. Used by the confirmation dialog.
|
||||
// workspaceId is the row's owning workspace; the backend rejects mismatches
|
||||
// with 404 so cross-workspace IDOR is closed off.
|
||||
export async function previewResetReviewWatch(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
const query = new URLSearchParams({ workspace_id: workspaceId });
|
||||
return fetchJson<{ taskCount: number }>(
|
||||
`/api/v1/github/watches/review/${id}/reset/preview?${query.toString()}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
// resetReviewWatch deletes every task previously created by the review
|
||||
// watch (including archived), wipes its dedup table, and nulls
|
||||
// last_polled_at so the next poll re-imports every currently-matching PR.
|
||||
export async function resetReviewWatch(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
const query = new URLSearchParams({ workspace_id: workspaceId });
|
||||
return fetchJson<{ tasksDeleted: number }>(
|
||||
`/api/v1/github/watches/review/${id}/reset?${query.toString()}`,
|
||||
{
|
||||
...options,
|
||||
init: { method: "POST", ...(options?.init ?? {}) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function triggerAllReviewWatches(workspaceId: string, options?: ApiRequestOptions) {
|
||||
const query = new URLSearchParams({ workspace_id: workspaceId });
|
||||
return fetchJson<TriggerReviewResponse>(
|
||||
@@ -401,6 +435,40 @@ export async function triggerIssueWatch(id: string, options?: ApiRequestOptions)
|
||||
});
|
||||
}
|
||||
|
||||
// previewResetIssueWatch returns how many tasks would be deleted if the
|
||||
// issue watch were reset. Used by the confirmation dialog. workspaceId is
|
||||
// the row's owning workspace; the backend rejects mismatches with 404.
|
||||
export async function previewResetIssueWatch(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
const query = new URLSearchParams({ workspace_id: workspaceId });
|
||||
return fetchJson<{ taskCount: number }>(
|
||||
`/api/v1/github/watches/issue/${id}/reset/preview?${query.toString()}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
// resetIssueWatch deletes every task previously created by the issue
|
||||
// watch (including archived), wipes its dedup table, and nulls
|
||||
// last_polled_at so the next poll re-imports every currently-matching
|
||||
// issue.
|
||||
export async function resetIssueWatch(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
const query = new URLSearchParams({ workspace_id: workspaceId });
|
||||
return fetchJson<{ tasksDeleted: number }>(
|
||||
`/api/v1/github/watches/issue/${id}/reset?${query.toString()}`,
|
||||
{
|
||||
...options,
|
||||
init: { method: "POST", ...(options?.init ?? {}) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function triggerAllIssueWatches(workspaceId: string, options?: ApiRequestOptions) {
|
||||
const query = new URLSearchParams({ workspace_id: workspaceId });
|
||||
return fetchJson<TriggerIssueResponse>(
|
||||
|
||||
@@ -139,3 +139,30 @@ export async function triggerJiraIssueWatch(
|
||||
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } },
|
||||
);
|
||||
}
|
||||
|
||||
// previewResetJiraIssueWatch returns how many tasks would be deleted if the
|
||||
// watch were reset. Used by the confirmation dialog.
|
||||
export async function previewResetJiraIssueWatch(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<{ taskCount: number }>(
|
||||
`/api/v1/jira/watches/issue/${encodeURIComponent(id)}/reset/preview?workspace_id=${encodeURIComponent(workspaceId)}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
// resetJiraIssueWatch deletes every task previously created by the watch
|
||||
// (including archived), wipes its dedup table, and nulls last_polled_at so
|
||||
// the next poll re-imports every currently-matching ticket.
|
||||
export async function resetJiraIssueWatch(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<{ tasksDeleted: number }>(
|
||||
`/api/v1/jira/watches/issue/${encodeURIComponent(id)}/reset?workspace_id=${encodeURIComponent(workspaceId)}`,
|
||||
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,3 +190,30 @@ export async function triggerLinearIssueWatch(
|
||||
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } },
|
||||
);
|
||||
}
|
||||
|
||||
// previewResetLinearIssueWatch returns how many tasks would be deleted if
|
||||
// the watch were reset. Used by the confirmation dialog.
|
||||
export async function previewResetLinearIssueWatch(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<{ taskCount: number }>(
|
||||
`/api/v1/linear/watches/issue/${encodeURIComponent(id)}/reset/preview?workspace_id=${encodeURIComponent(workspaceId)}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
// resetLinearIssueWatch deletes every task previously created by the watch
|
||||
// (including archived), wipes its dedup table, and nulls last_polled_at so
|
||||
// the next poll re-imports every currently-matching issue.
|
||||
export async function resetLinearIssueWatch(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<{ tasksDeleted: number }>(
|
||||
`/api/v1/linear/watches/issue/${encodeURIComponent(id)}/reset?workspace_id=${encodeURIComponent(workspaceId)}`,
|
||||
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,3 +166,30 @@ export async function triggerSentryIssueWatch(
|
||||
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } },
|
||||
);
|
||||
}
|
||||
|
||||
// previewResetSentryIssueWatch returns how many tasks would be deleted if
|
||||
// the watch were reset. Used by the confirmation dialog.
|
||||
export async function previewResetSentryIssueWatch(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<{ taskCount: number }>(
|
||||
`/api/v1/sentry/watches/issue/${encodeURIComponent(id)}/reset/preview?workspace_id=${encodeURIComponent(workspaceId)}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
// resetSentryIssueWatch deletes every task previously created by the watch
|
||||
// (including archived), wipes its dedup table, and nulls last_polled_at so
|
||||
// the next poll re-imports every currently-matching issue.
|
||||
export async function resetSentryIssueWatch(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<{ tasksDeleted: number }>(
|
||||
`/api/v1/sentry/watches/issue/${encodeURIComponent(id)}/reset?workspace_id=${encodeURIComponent(workspaceId)}`,
|
||||
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,6 +146,36 @@
|
||||
* `[session:state] newState=WAITING_FOR_INPUT` is the
|
||||
* smoking gun: the turn ended but running-state stuck.
|
||||
*
|
||||
* Review / Diff pipeline (bug: Review or Diff panel shows empty when
|
||||
* commits exist — distinguishes "no cumulative diff" vs "missing PR data"
|
||||
* vs "gh rate-limited" by tracing each source independently)
|
||||
* [review:cumulative] useCumulativeDiff fetch lifecycle —
|
||||
* fetch.start / fetch.success (with fileCount) /
|
||||
* fetch.error / cache.invalidated /
|
||||
* fetch.skip.in-flight / fetch.drain.pending. A
|
||||
* success with fileCount=0 means the backend
|
||||
* computed the merge-base diff and there are no
|
||||
* committed changes ahead of base. A skip.in-flight
|
||||
* followed by drain.pending then a fresh fetch.start
|
||||
* means an invalidation arrived mid-fetch and was
|
||||
* correctly coalesced (the Review dialog shows the
|
||||
* freshest worktree state, not the snapshot the
|
||||
* original git diff captured).
|
||||
* [review:pr-diff] usePRDiff fetch lifecycle — fetch.start /
|
||||
* fetch.success (with fileCount) / fetch.error
|
||||
* (with error message — surfaces gh rate-limit
|
||||
* and auth failures as the literal error string).
|
||||
* [review:sources] Merged review/diff list — total + per-source
|
||||
* counts (uncommitted/committed/pr) + load flags
|
||||
* + hasPR + hasCumulativeDiff. The smoking-gun
|
||||
* line: total=0 with hasPR=true & hasCumulativeDiff=true
|
||||
* means both sources returned but were empty;
|
||||
* total=0 with prDiffLoading=true means the PR
|
||||
* fetch is still in flight; total=0 with
|
||||
* hasPR=true & no preceding [review:pr-diff]
|
||||
* fetch.success/error means the PR fetch never
|
||||
* fired (look at useActiveTaskPR plumbing).
|
||||
*
|
||||
* Other
|
||||
* [ws:connection] WS hook mount + status transitions
|
||||
* [dockview:*] layout restore / save / env-switch / session-tabs / task-select
|
||||
|
||||
Reference in New Issue
Block a user