feat: add reset action for integration watches (#1381)

This commit is contained in:
Carlos Florêncio
2026-06-15 18:54:16 +01:00
committed by GitHub
parent 214a8f5cce
commit 61acc82b74
60 changed files with 3158 additions and 193 deletions
+16
View File
@@ -504,6 +504,22 @@ func registerRoutes(p routeParams) {
// runCanceller but its container leaks because the cascade bypasses // runCanceller but its container leaks because the cascade bypasses
// Service.ArchiveTask's runAsyncTaskCleanup branch. // Service.ArchiveTask's runAsyncTaskCleanup branch.
handoffSvc.SetTaskResourceCleaner(p.taskSvc) 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) p.orchestratorSvc.SetWorkspaceMaterializer(handoffSvc)
// Phase 8 prompt enrichment — wire the office scheduler's // Phase 8 prompt enrichment — wire the office scheduler's
// TaskContextProvider so every run prompt rendered by the active // 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.PUT("/watches/review/:id", c.httpUpdateReviewWatch)
api.DELETE("/watches/review/:id", c.httpDeleteReviewWatch) api.DELETE("/watches/review/:id", c.httpDeleteReviewWatch)
api.POST("/watches/review/:id/trigger", c.httpTriggerReviewWatch) 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.POST("/watches/review/trigger-all", c.httpTriggerAllReviewChecks)
api.GET("/watches/issue", c.httpListIssueWatches) 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.PUT("/watches/issue/:id", c.httpUpdateIssueWatch)
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch) api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch) 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("/watches/issue/trigger-all", c.httpTriggerAllIssueChecks)
api.POST("/cleanup/review-tasks", c.httpCleanupReviewTasks) 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"})
}
+7
View File
@@ -43,6 +43,13 @@ var ErrInvalidToken = errors.New("invalid token")
// transient upstream blip. // transient upstream blip.
var ErrRepoNotResolvable = errors.New("github: repository not resolvable") 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 // repoNotResolvableSubstrings matches the wire-format substrings GitHub's
// GraphQL/REST APIs use for a deterministic "repository does not exist / // GraphQL/REST APIs use for a deterministic "repository does not exist /
// not accessible" outcome: // not accessible" outcome:
+26 -9
View File
@@ -10,6 +10,7 @@ import (
"github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/watchreset"
) )
// Auth method constants. // Auth method constants.
@@ -70,15 +71,21 @@ type SecretManager interface {
// Service coordinates GitHub integration operations. // Service coordinates GitHub integration operations.
type Service struct { type Service struct {
mu sync.Mutex mu sync.Mutex
client Client client Client
authMethod string authMethod string
secrets SecretProvider secrets SecretProvider
secretManager SecretManager secretManager SecretManager
store *Store store *Store
eventBus bus.EventBus eventBus bus.EventBus
logger *logger.Logger logger *logger.Logger
taskDeleter TaskDeleter 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 taskSessionChecker TaskSessionChecker
syncGroup singleflight.Group syncGroup singleflight.Group
taskEventSubs []bus.Subscription taskEventSubs []bus.Subscription
@@ -176,6 +183,16 @@ func (s *Service) newPATClient(token string) *PATClient {
// SetTaskDeleter sets the task deletion dependency for cleanup operations. // SetTaskDeleter sets the task deletion dependency for cleanup operations.
func (s *Service) SetTaskDeleter(d TaskDeleter) { s.taskDeleter = d } 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. // SetTaskSessionChecker sets the session checker for cleanup operations.
func (s *Service) SetTaskSessionChecker(c TaskSessionChecker) { s.taskSessionChecker = c } 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
}
+64
View File
@@ -1236,6 +1236,38 @@ func (s *Store) DeleteReviewPRTask(ctx context.Context, id string) error {
return err 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 --- // --- Stats queries ---
// prStatsQuery builds parameterised SELECT queries against the github_task_prs table. // 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 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 --- // --- Action preset operations ---
// GetActionPresets returns stored PR/Issue presets for a workspace. Returns // 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)
}
}
+35
View File
@@ -43,6 +43,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch) api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch)
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch) api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch) 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 --- // --- HTTP handlers ---
@@ -242,6 +244,39 @@ func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"newIssues": len(tickets)}) 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 // assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the
// caller must supply `?workspace_id=...` matching the watch's workspace. The // caller must supply `?workspace_id=...` matching the watch's workspace. The
// list/create endpoints already require this query; mirroring it here closes // list/create endpoints already require this query; mirroring it here closes
+54
View File
@@ -13,6 +13,7 @@ import (
"github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus" "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 // 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. client Client // singleton, cleared on config change.
probeHook func() probeHook func()
eventBus bus.EventBus 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 // 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 // (KANDEV_MOCK_JIRA=true). Exposed via MockClient() so the e2e control routes
// can drive the same instance the clientFn returns. // can drive the same instance the clientFn returns.
mockClient *MockClient 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 // MockClient returns the shared mock client when the service was built in mock
// mode, or nil for production builds. // mode, or nil for production builds.
func (s *Service) MockClient() *MockClient { 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) 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 // 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 // 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" // whether the search succeeded — a failing search still counts as "we tried"
+32
View File
@@ -627,3 +627,35 @@ func (s *Store) ListSeenIssueKeys(ctx context.Context, watchID string) (map[stri
} }
return out, nil 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) { func TestStore_IssueWatchTask_Release(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
ctx := context.Background() ctx := context.Background()
+2
View File
@@ -49,6 +49,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch) api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch)
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch) api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch) 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 --- // --- HTTP handlers ---
@@ -97,6 +97,39 @@ func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"newIssues": len(issues)}) 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 // assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the
// caller must supply `?workspace_id=...` matching the watch's workspace. // caller must supply `?workspace_id=...` matching the watch's workspace.
// Writes the response and returns false on mismatch. // Writes the response and returns false on mismatch.
+14
View File
@@ -11,6 +11,7 @@ import (
"github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/watchreset"
) )
// SecretStore is the subset of the secrets store the service needs. // 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. client Client // singleton, cleared on config change.
probeHook func() probeHook func()
eventBus bus.EventBus 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 // mockClient is non-nil only when Provide built the service with a MockClient
// (KANDEV_MOCK_LINEAR=true). Exposed via MockClient() so the e2e control // (KANDEV_MOCK_LINEAR=true). Exposed via MockClient() so the e2e control
// routes can drive the same instance the clientFn returns. // routes can drive the same instance the clientFn returns.
mockClient *MockClient 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 // MockClient returns the shared mock client when the service was built in mock
// mode, or nil for production builds. // mode, or nil for production builds.
func (s *Service) MockClient() *MockClient { func (s *Service) MockClient() *MockClient {
@@ -12,6 +12,7 @@ import (
"github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/watchreset"
) )
// ErrIssueWatchNotFound is returned when GetIssueWatch's caller looks up an ID // 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) 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 // 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 // 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". // 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 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") 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.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch)
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch) api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch) 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 // 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)}) 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 // assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the
// caller must supply `?workspace_id=...` matching the watch's workspace. // caller must supply `?workspace_id=...` matching the watch's workspace.
// Writes the response and returns false on mismatch. // Writes the response and returns false on mismatch.
+14
View File
@@ -13,6 +13,7 @@ import (
"github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/watchreset"
) )
// SecretStore is the subset of the secrets store the service needs. // 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 // NewSentryIssueEvent. Optional: when nil, observed issues are not
// surfaced to the orchestrator. // surfaced to the orchestrator.
eventBus bus.EventBus 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 // 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"
"github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/watchreset"
) )
// ErrIssueWatchNotFound is returned when GetIssueWatch's caller looks up an ID // 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) 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 // 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 // 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". // 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 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") 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 package metrics
import ( import (
"bufio"
"context" "context"
"errors"
"fmt" "fmt"
"math" "math"
"os"
"path/filepath"
"strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -81,10 +75,10 @@ func (c *Collector) sampleMetric(ctx context.Context, id string, diskPath string
value, err := diskPercent(ctx, diskPath) value, err := diskPercent(ctx, diskPath)
return sample(id, "Disk", "%", value, err) return sample(id, "Disk", "%", value, err)
case MetricCPUTemp: case MetricCPUTemp:
value, err := cpuTemp(c.sysRoot) value, err := c.cpuTempValue()
return sample(id, "CPU temp", "C", value, err) return sample(id, "CPU temp", "C", value, err)
case MetricIOLoad: case MetricIOLoad:
value, err := ioLoad(c.procRoot) value, err := c.ioLoadValue()
return sample(id, "Load avg", "", value, err) return sample(id, "Load avg", "", value, err)
default: default:
return MetricSample{ID: id, Label: id, Available: false, Error: "unknown metric"} return MetricSample{ID: id, Label: id, Available: false, Error: "unknown metric"}
@@ -146,7 +140,7 @@ type cpuTimes struct {
} }
func (c *Collector) cpuPercent() (float64, error) { func (c *Collector) cpuPercent() (float64, error) {
current, err := readProcStat(filepath.Join(c.procRoot, "stat")) current, err := readCPUTimes(c)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -165,30 +159,6 @@ func (c *Collector) cpuPercent() (float64, error) {
return value, nil 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 { func calculateCPUPercent(prev, current cpuTimes) float64 {
totalDelta := current.total - prev.total totalDelta := current.total - prev.total
if totalDelta == 0 { if totalDelta == 0 {
@@ -198,101 +168,6 @@ func calculateCPUPercent(prev, current cpuTimes) float64 {
return (1 - float64(idleDelta)/float64(totalDelta)) * 100 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 { func (c *Collector) SampleWithTimestamp(ctx context.Context, settings GlobalSettings) Snapshot {
source := c.Sample(ctx, settings.Metrics, settings.BackendDiskPath) source := c.Sample(ctx, settings.Metrics, settings.BackendDiskPath)
return Snapshot{ 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 ( import (
"context" "context"
"errors" "errors"
"fmt"
"time"
"golang.org/x/sys/windows"
) )
func diskPercent(_ context.Context, _ string) (float64, error) { const diskQueryTimeout = 2 * time.Second
return 0, errors.New("disk usage unavailable on windows")
// 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)
}
}
+132
View File
@@ -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))
}
}
+133 -6
View File
@@ -19,6 +19,7 @@ import { PRStatsPanel } from "./pr-stats";
import { useReviewWatches } from "@/hooks/domains/github/use-review-watches"; import { useReviewWatches } from "@/hooks/domains/github/use-review-watches";
import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches"; import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches";
import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; 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 { cleanupMergedReviewTasks, cleanupClosedIssueTasks } from "@/lib/api/domains/github-api";
import type { ReviewWatch, IssueWatch } from "@/lib/types/github"; import type { ReviewWatch, IssueWatch } from "@/lib/types/github";
@@ -66,7 +67,15 @@ function CleanupNowButton({
} }
function useWatchActions(workspaceId?: string | null) { 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 { toast } = useToast();
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -116,11 +125,47 @@ function useWatchActions(workspaceId?: string | null) {
[update, toast], [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) { 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 { toast } = useToast();
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -170,7 +215,35 @@ function useIssueWatchActions(workspaceId?: string | null) {
[update, toast], [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() { export function GitHubConnectionSection() {
@@ -232,17 +305,38 @@ export function GitHubIntegrationPage() {
// single flat table. Pass `undefined` to the hook so it fetches all rows; the // 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. // dialog's create flow asks the user to pick the workspace.
function ReviewWatchSection() { function ReviewWatchSection() {
const { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled } = const {
useWatchActions(undefined); watches,
create,
update,
previewReset,
handleDelete,
handleTrigger,
handleReset,
handleToggleEnabled,
} = useWatchActions(undefined);
const { toast } = useToast(); const { toast } = useToast();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingWatch, setEditingWatch] = useState<ReviewWatch | null>(null); 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) => { const handleEdit = useCallback((watch: ReviewWatch) => {
setEditingWatch(watch); setEditingWatch(watch);
setDialogOpen(true); 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 ( return (
<> <>
<SettingsSection <SettingsSection
@@ -273,6 +367,7 @@ function ReviewWatchSection() {
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onTrigger={handleTrigger} onTrigger={handleTrigger}
onReset={onResetClick}
onToggleEnabled={handleToggleEnabled} onToggleEnabled={handleToggleEnabled}
/> />
</CardContent> </CardContent>
@@ -292,6 +387,15 @@ function ReviewWatchSection() {
toast({ description: "Review watch updated", variant: "success" }); 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 { toast } = useToast();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingWatch, setEditingIssueWatch] = useState<IssueWatch | null>(null); 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) => { const handleEdit = useCallback((watch: IssueWatch) => {
setEditingIssueWatch(watch); setEditingIssueWatch(watch);
setDialogOpen(true); 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 ( return (
<> <>
<SettingsSection <SettingsSection
@@ -337,6 +454,7 @@ function IssueWatchSection() {
onEdit={handleEdit} onEdit={handleEdit}
onDelete={issueActions.handleDelete} onDelete={issueActions.handleDelete}
onTrigger={issueActions.handleTrigger} onTrigger={issueActions.handleTrigger}
onReset={onResetClick}
onToggleEnabled={issueActions.handleToggleEnabled} onToggleEnabled={issueActions.handleToggleEnabled}
/> />
</CardContent> </CardContent>
@@ -355,6 +473,15 @@ function IssueWatchSection() {
toast({ description: "Issue watch updated", variant: "success" }); 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"; "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 { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge"; import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
@@ -17,6 +23,7 @@ type IssueWatchTableProps = {
onEdit: (watch: IssueWatch) => void; onEdit: (watch: IssueWatch) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onTrigger: (id: string) => void; onTrigger: (id: string) => void;
onReset: (id: string) => void;
onToggleEnabled: (watch: IssueWatch) => void; onToggleEnabled: (watch: IssueWatch) => void;
}; };
@@ -47,10 +54,11 @@ type WatchActionsProps = {
watch: IssueWatch; watch: IssueWatch;
onToggleEnabled: (watch: IssueWatch) => void; onToggleEnabled: (watch: IssueWatch) => void;
onTrigger: (id: string) => void; onTrigger: (id: string) => void;
onReset: (id: string) => void;
onDelete: (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(); const { toast } = useToast();
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
@@ -91,6 +99,24 @@ function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActi
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Check now</TooltipContent> <TooltipContent>Check now</TooltipContent>
</Tooltip> </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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -117,6 +143,7 @@ export function IssueWatchTable({
onEdit, onEdit,
onDelete, onDelete,
onTrigger, onTrigger,
onReset,
onToggleEnabled, onToggleEnabled,
}: IssueWatchTableProps) { }: IssueWatchTableProps) {
const workspaces = useAppStore((s) => s.workspaces.items); const workspaces = useAppStore((s) => s.workspaces.items);
@@ -171,6 +198,7 @@ export function IssueWatchTable({
watch={watch} watch={watch}
onToggleEnabled={onToggleEnabled} onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger} onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete} onDelete={onDelete}
/> />
</TableCell> </TableCell>
@@ -1,6 +1,12 @@
"use client"; "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 { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge"; import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
@@ -17,6 +23,7 @@ type ReviewWatchTableProps = {
onEdit: (watch: ReviewWatch) => void; onEdit: (watch: ReviewWatch) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onTrigger: (id: string) => void; onTrigger: (id: string) => void;
onReset: (id: string) => void;
onToggleEnabled: (watch: ReviewWatch) => void; onToggleEnabled: (watch: ReviewWatch) => void;
}; };
@@ -42,10 +49,11 @@ type WatchActionsProps = {
watch: ReviewWatch; watch: ReviewWatch;
onToggleEnabled: (watch: ReviewWatch) => void; onToggleEnabled: (watch: ReviewWatch) => void;
onTrigger: (id: string) => void; onTrigger: (id: string) => void;
onReset: (id: string) => void;
onDelete: (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(); const { toast } = useToast();
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
@@ -86,6 +94,24 @@ function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActi
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Check now</TooltipContent> <TooltipContent>Check now</TooltipContent>
</Tooltip> </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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -112,6 +138,7 @@ export function ReviewWatchTable({
onEdit, onEdit,
onDelete, onDelete,
onTrigger, onTrigger,
onReset,
onToggleEnabled, onToggleEnabled,
}: ReviewWatchTableProps) { }: ReviewWatchTableProps) {
const workspaces = useAppStore((s) => s.workspaces.items); const workspaces = useAppStore((s) => s.workspaces.items);
@@ -162,6 +189,7 @@ export function ReviewWatchTable({
watch={watch} watch={watch}
onToggleEnabled={onToggleEnabled} onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger} onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete} onDelete={onDelete}
/> />
</TableCell> </TableCell>
@@ -1,6 +1,12 @@
"use client"; "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 { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge"; import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
@@ -17,6 +23,7 @@ type JiraIssueWatchTableProps = {
onEdit: (watch: JiraIssueWatch) => void; onEdit: (watch: JiraIssueWatch) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onTrigger: (id: string) => void; onTrigger: (id: string) => void;
onReset: (id: string) => void;
onToggleEnabled: (watch: JiraIssueWatch) => void; onToggleEnabled: (watch: JiraIssueWatch) => void;
}; };
@@ -35,11 +42,13 @@ function WatchActions({
watch, watch,
onToggleEnabled, onToggleEnabled,
onTrigger, onTrigger,
onReset,
onDelete, onDelete,
}: { }: {
watch: JiraIssueWatch; watch: JiraIssueWatch;
onToggleEnabled: (watch: JiraIssueWatch) => void; onToggleEnabled: (watch: JiraIssueWatch) => void;
onTrigger: (id: string) => void; onTrigger: (id: string) => void;
onReset: (id: string) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}) { }) {
return ( return (
@@ -82,6 +91,24 @@ function WatchActions({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Check now</TooltipContent> <TooltipContent>Check now</TooltipContent>
</Tooltip> </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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -108,6 +135,7 @@ export function JiraIssueWatchTable({
onEdit, onEdit,
onDelete, onDelete,
onTrigger, onTrigger,
onReset,
onToggleEnabled, onToggleEnabled,
}: JiraIssueWatchTableProps) { }: JiraIssueWatchTableProps) {
const workspaces = useAppStore((s) => s.workspaces.items); const workspaces = useAppStore((s) => s.workspaces.items);
@@ -160,6 +188,7 @@ export function JiraIssueWatchTable({
watch={watch} watch={watch}
onToggleEnabled={onToggleEnabled} onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger} onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete} onDelete={onDelete}
/> />
</TableCell> </TableCell>
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@kandev/ui/card";
import { SettingsSection } from "@/components/settings/settings-section"; import { SettingsSection } from "@/components/settings/settings-section";
import { useToast } from "@/components/toast-provider"; import { useToast } from "@/components/toast-provider";
import { useJiraIssueWatches } from "@/hooks/domains/jira/use-jira-issue-watches"; 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 { JiraIssueWatchTable } from "./jira-issue-watch-table";
import { JiraIssueWatchDialog } from "./jira-issue-watch-dialog"; import { JiraIssueWatchDialog } from "./jira-issue-watch-dialog";
import type { JiraIssueWatch } from "@/lib/types/jira"; import type { JiraIssueWatch } from "@/lib/types/jira";
@@ -20,12 +21,13 @@ type RawActions = {
update: ReturnType<typeof useJiraIssueWatches>["update"]; update: ReturnType<typeof useJiraIssueWatches>["update"];
remove: ReturnType<typeof useJiraIssueWatches>["remove"]; remove: ReturnType<typeof useJiraIssueWatches>["remove"];
trigger: ReturnType<typeof useJiraIssueWatches>["trigger"]; trigger: ReturnType<typeof useJiraIssueWatches>["trigger"];
reset: ReturnType<typeof useJiraIssueWatches>["reset"];
}; };
// useToastedActions wraps the raw create/update/delete/trigger callbacks with // useToastedActions wraps the raw create/update/delete/trigger callbacks with
// success/failure toasts. Per-row mutations need each watch's own workspaceId, // 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. // 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 { toast } = useToast();
const wrappedCreate = useCallback( const wrappedCreate = useCallback(
@@ -95,22 +97,52 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) {
[update, toast], [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 { return {
create: wrappedCreate, create: wrappedCreate,
update: wrappedUpdate, update: wrappedUpdate,
remove: wrappedDelete, remove: wrappedDelete,
trigger: wrappedTrigger, trigger: wrappedTrigger,
reset: wrappedReset,
toggleEnabled, toggleEnabled,
}; };
} }
export function JiraIssueWatchersSection() { export function JiraIssueWatchersSection() {
// Pass undefined to fetch every watch across every workspace. // Pass undefined to fetch every watch across every workspace.
const { items, loading, create, update, remove, trigger } = useJiraIssueWatches(); const { items, loading, create, update, remove, trigger, previewReset, reset } =
const actions = useToastedActions({ create, update, remove, trigger }); useJiraIssueWatches();
const actions = useToastedActions({ create, update, remove, trigger, reset });
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<JiraIssueWatch | null>(null); 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(() => { const openCreate = useCallback(() => {
setEditing(null); setEditing(null);
@@ -138,6 +170,14 @@ export function JiraIssueWatchersSection() {
}, },
[items, actions], [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 ( return (
<SettingsSection <SettingsSection
@@ -162,6 +202,7 @@ export function JiraIssueWatchersSection() {
onEdit={openEdit} onEdit={openEdit}
onDelete={handleDelete} onDelete={handleDelete}
onTrigger={handleTrigger} onTrigger={handleTrigger}
onReset={handleReset}
onToggleEnabled={actions.toggleEnabled} onToggleEnabled={actions.toggleEnabled}
/> />
)} )}
@@ -180,6 +221,15 @@ export function JiraIssueWatchersSection() {
return actions.update(id, req, w.workspaceId); return actions.update(id, req, w.workspaceId);
}} }}
/> />
{resetCtrl.resetting && (
<ResetWatchDialog
open
onOpenChange={resetCtrl.onOpenChange}
integrationLabel="JIRA watcher"
previewLoader={resetCtrl.previewLoader}
onConfirm={resetCtrl.confirmReset}
/>
)}
</SettingsSection> </SettingsSection>
); );
} }
@@ -1,6 +1,12 @@
"use client"; "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 { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge"; import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
@@ -16,6 +22,7 @@ type LinearIssueWatchTableProps = {
onEdit: (watch: LinearIssueWatch) => void; onEdit: (watch: LinearIssueWatch) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onTrigger: (id: string) => void; onTrigger: (id: string) => void;
onReset: (id: string) => void;
onToggleEnabled: (watch: LinearIssueWatch) => void; onToggleEnabled: (watch: LinearIssueWatch) => void;
}; };
@@ -50,11 +57,13 @@ function WatchActions({
watch, watch,
onToggleEnabled, onToggleEnabled,
onTrigger, onTrigger,
onReset,
onDelete, onDelete,
}: { }: {
watch: LinearIssueWatch; watch: LinearIssueWatch;
onToggleEnabled: (watch: LinearIssueWatch) => void; onToggleEnabled: (watch: LinearIssueWatch) => void;
onTrigger: (id: string) => void; onTrigger: (id: string) => void;
onReset: (id: string) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}) { }) {
return ( return (
@@ -95,6 +104,24 @@ function WatchActions({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Check now</TooltipContent> <TooltipContent>Check now</TooltipContent>
</Tooltip> </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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -121,6 +148,7 @@ export function LinearIssueWatchTable({
onEdit, onEdit,
onDelete, onDelete,
onTrigger, onTrigger,
onReset,
onToggleEnabled, onToggleEnabled,
}: LinearIssueWatchTableProps) { }: LinearIssueWatchTableProps) {
const workspaces = useAppStore((s) => s.workspaces.items); const workspaces = useAppStore((s) => s.workspaces.items);
@@ -176,6 +204,7 @@ export function LinearIssueWatchTable({
watch={watch} watch={watch}
onToggleEnabled={onToggleEnabled} onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger} onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete} onDelete={onDelete}
/> />
</TableCell> </TableCell>
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@kandev/ui/card";
import { SettingsSection } from "@/components/settings/settings-section"; import { SettingsSection } from "@/components/settings/settings-section";
import { useToast } from "@/components/toast-provider"; import { useToast } from "@/components/toast-provider";
import { useLinearIssueWatches } from "@/hooks/domains/linear/use-linear-issue-watches"; 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 { LinearIssueWatchTable } from "./linear-issue-watch-table";
import { LinearIssueWatchDialog } from "./linear-issue-watch-dialog"; import { LinearIssueWatchDialog } from "./linear-issue-watch-dialog";
import type { LinearIssueWatch } from "@/lib/types/linear"; import type { LinearIssueWatch } from "@/lib/types/linear";
@@ -20,9 +21,10 @@ type RawActions = {
update: ReturnType<typeof useLinearIssueWatches>["update"]; update: ReturnType<typeof useLinearIssueWatches>["update"];
remove: ReturnType<typeof useLinearIssueWatches>["remove"]; remove: ReturnType<typeof useLinearIssueWatches>["remove"];
trigger: ReturnType<typeof useLinearIssueWatches>["trigger"]; 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 { toast } = useToast();
const wrappedCreate = useCallback( const wrappedCreate = useCallback(
@@ -90,21 +92,47 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) {
[update, toast], [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 { return {
create: wrappedCreate, create: wrappedCreate,
update: wrappedUpdate, update: wrappedUpdate,
remove: wrappedDelete, remove: wrappedDelete,
trigger: wrappedTrigger, trigger: wrappedTrigger,
reset: wrappedReset,
toggleEnabled, toggleEnabled,
}; };
} }
export function LinearIssueWatchersSection() { export function LinearIssueWatchersSection() {
const { items, loading, create, update, remove, trigger } = useLinearIssueWatches(); const { items, loading, create, update, remove, trigger, previewReset, reset } =
const actions = useToastedActions({ create, update, remove, trigger }); useLinearIssueWatches();
const actions = useToastedActions({ create, update, remove, trigger, reset });
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LinearIssueWatch | null>(null); 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(() => { const openCreate = useCallback(() => {
setEditing(null); setEditing(null);
@@ -132,6 +160,14 @@ export function LinearIssueWatchersSection() {
}, },
[items, actions], [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 ( return (
<SettingsSection <SettingsSection
@@ -156,6 +192,7 @@ export function LinearIssueWatchersSection() {
onEdit={openEdit} onEdit={openEdit}
onDelete={handleDelete} onDelete={handleDelete}
onTrigger={handleTrigger} onTrigger={handleTrigger}
onReset={handleReset}
onToggleEnabled={actions.toggleEnabled} onToggleEnabled={actions.toggleEnabled}
/> />
)} )}
@@ -172,6 +209,15 @@ export function LinearIssueWatchersSection() {
return actions.update(id, req, w.workspaceId); return actions.update(id, req, w.workspaceId);
}} }}
/> />
{resetCtrl.resetting && (
<ResetWatchDialog
open
onOpenChange={resetCtrl.onOpenChange}
integrationLabel="Linear watcher"
previewLoader={resetCtrl.previewLoader}
onConfirm={resetCtrl.confirmReset}
/>
)}
</SettingsSection> </SettingsSection>
); );
} }
+9 -2
View File
@@ -146,9 +146,16 @@ function buildAllFiles(
prDiffFiles?: PRDiffFile[], prDiffFiles?: PRDiffFile[],
): ReviewFile[] { ): ReviewFile[] {
const fileMap = new Map<string, ReviewFile>(); const fileMap = new Map<string, ReviewFile>();
if (prDiffFiles) addPRFiles(fileMap, prDiffFiles); // Order matters and must match `buildReviewSources` (the Changes panel
if (cumulativeDiff?.files) addCumulativeDiffFiles(fileMap, cumulativeDiff.files, gitStatusFiles); // 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 (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)); return Array.from(fileMap.values()).sort((a, b) => a.path.localeCompare(b.path));
} }
@@ -1,6 +1,12 @@
"use client"; "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 { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge"; import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
@@ -16,6 +22,7 @@ type SentryIssueWatchTableProps = {
onEdit: (watch: SentryIssueWatch) => void; onEdit: (watch: SentryIssueWatch) => void;
onDelete: (id: string, workspaceId: string) => void; onDelete: (id: string, workspaceId: string) => void;
onTrigger: (id: string, workspaceId: string) => void; onTrigger: (id: string, workspaceId: string) => void;
onReset: (id: string, workspaceId: string) => void;
onToggleEnabled: (watch: SentryIssueWatch) => void; onToggleEnabled: (watch: SentryIssueWatch) => void;
}; };
@@ -55,11 +62,13 @@ function WatchActions({
watch, watch,
onToggleEnabled, onToggleEnabled,
onTrigger, onTrigger,
onReset,
onDelete, onDelete,
}: { }: {
watch: SentryIssueWatch; watch: SentryIssueWatch;
onToggleEnabled: (watch: SentryIssueWatch) => void; onToggleEnabled: (watch: SentryIssueWatch) => void;
onTrigger: (id: string, workspaceId: string) => void; onTrigger: (id: string, workspaceId: string) => void;
onReset: (id: string, workspaceId: string) => void;
onDelete: (id: string, workspaceId: string) => void; onDelete: (id: string, workspaceId: string) => void;
}) { }) {
return ( return (
@@ -100,6 +109,24 @@ function WatchActions({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Check now</TooltipContent> <TooltipContent>Check now</TooltipContent>
</Tooltip> </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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -126,6 +153,7 @@ export function SentryIssueWatchTable({
onEdit, onEdit,
onDelete, onDelete,
onTrigger, onTrigger,
onReset,
onToggleEnabled, onToggleEnabled,
}: SentryIssueWatchTableProps) { }: SentryIssueWatchTableProps) {
const workspaces = useAppStore((s) => s.workspaces.items); const workspaces = useAppStore((s) => s.workspaces.items);
@@ -181,6 +209,7 @@ export function SentryIssueWatchTable({
watch={watch} watch={watch}
onToggleEnabled={onToggleEnabled} onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger} onTrigger={onTrigger}
onReset={onReset}
onDelete={onDelete} onDelete={onDelete}
/> />
</TableCell> </TableCell>
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@kandev/ui/card";
import { SettingsSection } from "@/components/settings/settings-section"; import { SettingsSection } from "@/components/settings/settings-section";
import { useToast } from "@/components/toast-provider"; import { useToast } from "@/components/toast-provider";
import { useSentryIssueWatches } from "@/hooks/domains/sentry/use-sentry-issue-watches"; 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 { SentryIssueWatchTable } from "./sentry-issue-watch-table";
import { SentryIssueWatchDialog } from "./sentry-issue-watch-dialog"; import { SentryIssueWatchDialog } from "./sentry-issue-watch-dialog";
import type { SentryIssueWatch } from "@/lib/types/sentry"; import type { SentryIssueWatch } from "@/lib/types/sentry";
@@ -19,9 +20,10 @@ type RawActions = {
update: ReturnType<typeof useSentryIssueWatches>["update"]; update: ReturnType<typeof useSentryIssueWatches>["update"];
remove: ReturnType<typeof useSentryIssueWatches>["remove"]; remove: ReturnType<typeof useSentryIssueWatches>["remove"];
trigger: ReturnType<typeof useSentryIssueWatches>["trigger"]; 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 { toast } = useToast();
const wrappedCreate = useCallback( const wrappedCreate = useCallback(
@@ -89,21 +91,47 @@ function useToastedActions({ create, update, remove, trigger }: RawActions) {
[update, toast], [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 { return {
create: wrappedCreate, create: wrappedCreate,
update: wrappedUpdate, update: wrappedUpdate,
remove: wrappedDelete, remove: wrappedDelete,
trigger: wrappedTrigger, trigger: wrappedTrigger,
reset: wrappedReset,
toggleEnabled, toggleEnabled,
}; };
} }
export function SentryIssueWatchersSection() { export function SentryIssueWatchersSection() {
const { items, loading, create, update, remove, trigger } = useSentryIssueWatches(); const { items, loading, create, update, remove, trigger, previewReset, reset } =
const actions = useToastedActions({ create, update, remove, trigger }); useSentryIssueWatches();
const actions = useToastedActions({ create, update, remove, trigger, reset });
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<SentryIssueWatch | null>(null); 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(() => { const openCreate = useCallback(() => {
setEditing(null); setEditing(null);
@@ -114,6 +142,15 @@ export function SentryIssueWatchersSection() {
setDialogOpen(true); 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 ( return (
<SettingsSection <SettingsSection
icon={<IconBellRinging className="h-5 w-5" />} icon={<IconBellRinging className="h-5 w-5" />}
@@ -137,6 +174,7 @@ export function SentryIssueWatchersSection() {
onEdit={openEdit} onEdit={openEdit}
onDelete={actions.remove} onDelete={actions.remove}
onTrigger={actions.trigger} onTrigger={actions.trigger}
onReset={handleReset}
onToggleEnabled={actions.toggleEnabled} onToggleEnabled={actions.toggleEnabled}
/> />
)} )}
@@ -149,6 +187,15 @@ export function SentryIssueWatchersSection() {
onCreate={actions.create} onCreate={actions.create}
onUpdate={actions.update} onUpdate={actions.update}
/> />
{resetCtrl.resetting && (
<ResetWatchDialog
open
onOpenChange={resetCtrl.onOpenChange}
integrationLabel="Sentry watcher"
previewLoader={resetCtrl.previewLoader}
onConfirm={resetCtrl.confirmReset}
/>
)}
</SettingsSection> </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, deleteIssueWatch,
triggerIssueWatch, triggerIssueWatch,
triggerAllIssueWatches, triggerAllIssueWatches,
previewResetIssueWatch,
resetIssueWatch,
} from "@/lib/api/domains/github-api"; } 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"; import type { CreateIssueWatchRequest, UpdateIssueWatchRequest } from "@/lib/types/github";
// useIssueWatches has three modes: // useIssueWatches has three modes:
@@ -25,6 +27,10 @@ export function useIssueWatches(workspaceId?: string | null) {
const addWatch = useAppStore((state) => state.addIssueWatch); const addWatch = useAppStore((state) => state.addIssueWatch);
const updateWatch = useAppStore((state) => state.updateIssueWatch); const updateWatch = useAppStore((state) => state.updateIssueWatch);
const removeWatch = useAppStore((state) => state.removeIssueWatch); 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(() => { useEffect(() => {
if (workspaceId === null || loaded || loading) return; if (workspaceId === null || loaded || loading) return;
@@ -76,6 +82,22 @@ export function useIssueWatches(workspaceId?: string | null) {
return triggerAllIssueWatches(workspaceId); return triggerAllIssueWatches(workspaceId);
}, [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 { return {
items, items,
loaded, loaded,
@@ -85,5 +107,7 @@ export function useIssueWatches(workspaceId?: string | null) {
remove, remove,
trigger, trigger,
triggerAll, triggerAll,
previewReset,
reset,
}; };
} }
+10 -6
View File
@@ -2,8 +2,11 @@
import { useEffect, useCallback, useState, useRef } from "react"; import { useEffect, useCallback, useState, useRef } from "react";
import { getWebSocketClient } from "@/lib/ws/connection"; import { getWebSocketClient } from "@/lib/ws/connection";
import { createDebugLogger } from "@/lib/debug/log";
import type { PRDiffFile } from "@/lib/types/github"; import type { PRDiffFile } from "@/lib/types/github";
const debug = createDebugLogger("review:pr-diff");
type PRDiffState = { type PRDiffState = {
files: PRDiffFile[]; files: PRDiffFile[];
loading: boolean; loading: boolean;
@@ -26,19 +29,20 @@ async function fetchPRFiles(
if (!client) return; if (!client) return;
setState({ files: [], loading: true, error: null }); setState({ files: [], loading: true, error: null });
debug("fetch.start", { owner, repo, prNumber });
try { try {
const response = await client.request<{ files?: PRDiffFile[] }>("github.pr_files.get", { const response = await client.request<{ files?: PRDiffFile[] }>("github.pr_files.get", {
owner, owner,
repo, repo,
number: prNumber, 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) { } catch (err) {
setState({ const message = err instanceof Error ? err.message : "Failed to fetch PR files";
files: [], setState({ files: [], loading: false, error: message });
loading: false, debug("fetch.error", { owner, repo, prNumber, error: message });
error: err instanceof Error ? err.message : "Failed to fetch PR files",
});
} }
} }
@@ -8,8 +8,10 @@ import {
deleteReviewWatch, deleteReviewWatch,
triggerReviewWatch, triggerReviewWatch,
triggerAllReviewWatches, triggerAllReviewWatches,
previewResetReviewWatch,
resetReviewWatch,
} from "@/lib/api/domains/github-api"; } 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"; import type { CreateReviewWatchRequest, UpdateReviewWatchRequest } from "@/lib/types/github";
// useReviewWatches has three modes: // useReviewWatches has three modes:
@@ -25,6 +27,10 @@ export function useReviewWatches(workspaceId?: string | null) {
const addWatch = useAppStore((state) => state.addReviewWatch); const addWatch = useAppStore((state) => state.addReviewWatch);
const updateWatch = useAppStore((state) => state.updateReviewWatch); const updateWatch = useAppStore((state) => state.updateReviewWatch);
const removeWatch = useAppStore((state) => state.removeReviewWatch); 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(() => { useEffect(() => {
if (workspaceId === null || loaded || loading) return; if (workspaceId === null || loaded || loading) return;
@@ -76,6 +82,22 @@ export function useReviewWatches(workspaceId?: string | null) {
return triggerAllReviewWatches(workspaceId); return triggerAllReviewWatches(workspaceId);
}, [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 { return {
items, items,
loaded, loaded,
@@ -85,5 +107,7 @@ export function useReviewWatches(workspaceId?: string | null) {
remove, remove,
trigger, trigger,
triggerAll, triggerAll,
previewReset,
reset,
}; };
} }
@@ -7,10 +7,17 @@ import {
updateJiraIssueWatch, updateJiraIssueWatch,
deleteJiraIssueWatch, deleteJiraIssueWatch,
triggerJiraIssueWatch, triggerJiraIssueWatch,
previewResetJiraIssueWatch,
resetJiraIssueWatch,
} from "@/lib/api/domains/jira-api"; } from "@/lib/api/domains/jira-api";
import { useAppStore } from "@/components/state-provider"; import { useAppStore } from "@/components/state-provider";
import type { CreateJiraIssueWatchInput, UpdateJiraIssueWatchInput } from "@/lib/types/jira"; 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: * useJiraIssueWatches owns the JIRA-watcher list:
* - workspaceId: string → fetch and operate on watches in one workspace * - workspaceId: string → fetch and operate on watches in one workspace
@@ -72,7 +79,7 @@ export function useJiraIssueWatches(workspaceId?: string | null) {
const update = useCallback( const update = useCallback(
async (id: string, req: UpdateJiraIssueWatchInput, rowWorkspaceId?: string) => { async (id: string, req: UpdateJiraIssueWatchInput, rowWorkspaceId?: string) => {
const ws = rowWorkspaceId ?? workspaceId; 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); const watch = await updateJiraIssueWatch(ws, id, req);
updateWatch(watch); updateWatch(watch);
return watch; return watch;
@@ -83,7 +90,7 @@ export function useJiraIssueWatches(workspaceId?: string | null) {
const remove = useCallback( const remove = useCallback(
async (id: string, rowWorkspaceId?: string) => { async (id: string, rowWorkspaceId?: string) => {
const ws = rowWorkspaceId ?? workspaceId; const ws = rowWorkspaceId ?? workspaceId;
if (!ws) throw new Error("workspaceId required"); if (!ws) throw new Error(WORKSPACE_REQUIRED);
await deleteJiraIssueWatch(ws, id); await deleteJiraIssueWatch(ws, id);
removeWatch(id); removeWatch(id);
}, },
@@ -93,11 +100,36 @@ export function useJiraIssueWatches(workspaceId?: string | null) {
const trigger = useCallback( const trigger = useCallback(
async (id: string, rowWorkspaceId?: string) => { async (id: string, rowWorkspaceId?: string) => {
const ws = rowWorkspaceId ?? workspaceId; const ws = rowWorkspaceId ?? workspaceId;
if (!ws) throw new Error("workspaceId required"); if (!ws) throw new Error(WORKSPACE_REQUIRED);
return triggerJiraIssueWatch(ws, id); return triggerJiraIssueWatch(ws, id);
}, },
[workspaceId], [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, updateLinearIssueWatch,
deleteLinearIssueWatch, deleteLinearIssueWatch,
triggerLinearIssueWatch, triggerLinearIssueWatch,
previewResetLinearIssueWatch,
resetLinearIssueWatch,
} from "@/lib/api/domains/linear-api"; } from "@/lib/api/domains/linear-api";
import { useAppStore } from "@/components/state-provider"; import { useAppStore } from "@/components/state-provider";
import type { CreateLinearIssueWatchInput, UpdateLinearIssueWatchInput } from "@/lib/types/linear"; 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: * useLinearIssueWatches owns the Linear-watcher list:
* - workspaceId: string → fetch and operate on watches in one workspace * - workspaceId: string → fetch and operate on watches in one workspace
@@ -69,7 +75,7 @@ export function useLinearIssueWatches(workspaceId?: string | null) {
const update = useCallback( const update = useCallback(
async (id: string, req: UpdateLinearIssueWatchInput, rowWorkspaceId?: string) => { async (id: string, req: UpdateLinearIssueWatchInput, rowWorkspaceId?: string) => {
const ws = rowWorkspaceId ?? workspaceId; 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); const watch = await updateLinearIssueWatch(ws, id, req);
updateWatch(watch); updateWatch(watch);
return watch; return watch;
@@ -80,7 +86,7 @@ export function useLinearIssueWatches(workspaceId?: string | null) {
const remove = useCallback( const remove = useCallback(
async (id: string, rowWorkspaceId?: string) => { async (id: string, rowWorkspaceId?: string) => {
const ws = rowWorkspaceId ?? workspaceId; const ws = rowWorkspaceId ?? workspaceId;
if (!ws) throw new Error("workspaceId required"); if (!ws) throw new Error(WORKSPACE_REQUIRED);
await deleteLinearIssueWatch(ws, id); await deleteLinearIssueWatch(ws, id);
removeWatch(id); removeWatch(id);
}, },
@@ -90,11 +96,31 @@ export function useLinearIssueWatches(workspaceId?: string | null) {
const trigger = useCallback( const trigger = useCallback(
async (id: string, rowWorkspaceId?: string) => { async (id: string, rowWorkspaceId?: string) => {
const ws = rowWorkspaceId ?? workspaceId; const ws = rowWorkspaceId ?? workspaceId;
if (!ws) throw new Error("workspaceId required"); if (!ws) throw new Error(WORKSPACE_REQUIRED);
return triggerLinearIssueWatch(ws, id); return triggerLinearIssueWatch(ws, id);
}, },
[workspaceId], [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, updateSentryIssueWatch,
deleteSentryIssueWatch, deleteSentryIssueWatch,
triggerSentryIssueWatch, triggerSentryIssueWatch,
previewResetSentryIssueWatch,
resetSentryIssueWatch,
} from "@/lib/api/domains/sentry-api"; } from "@/lib/api/domains/sentry-api";
import type { import type {
CreateSentryIssueWatchRequest, CreateSentryIssueWatchRequest,
@@ -104,5 +106,17 @@ export function useSentryIssueWatches(workspaceId?: string | null) {
return triggerSentryIssueWatch(id, watchWorkspaceId); 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 { useEffect, useCallback, useRef, useState } from "react";
import { useAppStore } from "@/components/state-provider"; import { useAppStore } from "@/components/state-provider";
import { getWebSocketClient } from "@/lib/ws/connection"; import { getWebSocketClient } from "@/lib/ws/connection";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import type { CumulativeDiff } from "@/lib/state/slices/session-runtime/types"; import type { CumulativeDiff } from "@/lib/state/slices/session-runtime/types";
const debug = createDebugLogger("review:cumulative");
const cumulativeDiffCache: Record<string, CumulativeDiff | null> = {}; const cumulativeDiffCache: Record<string, CumulativeDiff | null> = {};
const loadingState: Record<string, boolean> = {}; 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. * 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) { export function invalidateCumulativeDiffCache(envKey: string) {
delete cumulativeDiffCache[envKey]; 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) { export function useCumulativeDiff(sessionId: string | null) {
@@ -36,7 +82,15 @@ export function useCumulativeDiff(sessionId: string | null) {
const fetchCumulativeDiff = useCallback(async () => { const fetchCumulativeDiff = useCallback(async () => {
if (!sessionId || !envKey) return; 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(); const client = getWebSocketClient();
if (!client) return; if (!client) return;
@@ -46,6 +100,7 @@ export function useCumulativeDiff(sessionId: string | null) {
setLoading(true); setLoading(true);
loadingState[envKey] = true; loadingState[envKey] = true;
setError(null); setError(null);
debug("fetch.start", { sessionId, envKey });
try { try {
// Backend routes by session_id, but we cache by envKey // 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 // Discard if the environment changed while the request was in flight
if (version !== requestVersionRef.current) return; if (version !== requestVersionRef.current) return;
if (response?.cumulative_diff) { commitFetchedDiff(envKey, sessionId, response?.cumulative_diff ?? null, setDiff);
cumulativeDiffCache[envKey] = response.cumulative_diff;
setDiff(response.cumulative_diff);
}
} catch (err) { } catch (err) {
if (version !== requestVersionRef.current) return; if (version !== requestVersionRef.current) return;
console.error("Failed to fetch cumulative diff:", err); 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 { } finally {
if (version === requestVersionRef.current) { if (version === requestVersionRef.current) {
setLoading(false); setLoading(false);
} }
loadingState[envKey] = 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]); }, [sessionId, envKey]);
@@ -82,6 +145,7 @@ export function useCumulativeDiff(sessionId: string | null) {
requestVersionRef.current++; requestVersionRef.current++;
if (envKey) { if (envKey) {
loadingState[envKey] = false; loadingState[envKey] = false;
delete pendingInvalidationByEnvKey[envKey];
setDiff(cumulativeDiffCache[envKey] ?? null); setDiff(cumulativeDiffCache[envKey] ?? null);
} else { } else {
setDiff(null); setDiff(null);
@@ -95,11 +159,16 @@ export function useCumulativeDiff(sessionId: string | null) {
fetchCumulativeDiff(); fetchCumulativeDiff();
}, [envKey, invalidationCount, 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(() => { useEffect(() => {
if (!envKey) return; if (!envKey) return;
const handler = (invalidatedEnvKey: string) => { const handler = (event: ListenerEvent) => {
if (invalidatedEnvKey === envKey) { 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); 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 { useSessionGitStatus, useSessionGitStatusByRepo } from "./use-session-git-status";
import { useCumulativeDiff } from "./use-cumulative-diff"; import { useCumulativeDiff } from "./use-cumulative-diff";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { usePRDiff } from "@/hooks/domains/github/use-pr-diff"; import { usePRDiff } from "@/hooks/domains/github/use-pr-diff";
import { normalizeDiffContent } from "@/components/review/types"; import { normalizeDiffContent } from "@/components/review/types";
import { createDebugLogger } from "@/lib/debug/log";
import type { ReviewFile } from "@/components/review/types"; import type { ReviewFile } from "@/components/review/types";
import type { PRDiffFile } from "@/lib/types/github"; import type { PRDiffFile } from "@/lib/types/github";
const debug = createDebugLogger("review:sources");
export type ReviewSource = "uncommitted" | "committed" | "pr"; export type ReviewSource = "uncommitted" | "committed" | "pr";
export type SourceCounts = Record<ReviewSource, number>; export type SourceCounts = Record<ReviewSource, number>;
@@ -266,6 +269,35 @@ export function useReviewSources(sessionId: string | null | undefined): UseRevie
// the GitHub PR diff hydrates. // the GitHub PR diff hydrates.
const hasPR = !!pr; 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 { return {
allFiles, allFiles,
sourceCounts, sourceCounts,
+68
View File
@@ -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) { export async function triggerAllReviewWatches(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId }); const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<TriggerReviewResponse>( 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) { export async function triggerAllIssueWatches(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId }); const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<TriggerIssueResponse>( return fetchJson<TriggerIssueResponse>(
+27
View File
@@ -139,3 +139,30 @@ export async function triggerJiraIssueWatch(
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } }, { ...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" } },
);
}
+27
View File
@@ -190,3 +190,30 @@ export async function triggerLinearIssueWatch(
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } }, { ...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" } },
);
}
+27
View File
@@ -166,3 +166,30 @@ export async function triggerSentryIssueWatch(
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } }, { ...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" } },
);
}
+30
View File
@@ -146,6 +146,36 @@
* `[session:state] newState=WAITING_FOR_INPUT` is the * `[session:state] newState=WAITING_FOR_INPUT` is the
* smoking gun: the turn ended but running-state stuck. * 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 * Other
* [ws:connection] WS hook mount + status transitions * [ws:connection] WS hook mount + status transitions
* [dockview:*] layout restore / save / env-switch / session-tabs / task-select * [dockview:*] layout restore / save / env-switch / session-tabs / task-select
+1 -1
View File
@@ -3,7 +3,7 @@ node = "24"
pnpm = "9.15.9" pnpm = "9.15.9"
go = "1.26.0" go = "1.26.0"
jq = "1.8.1" jq = "1.8.1"
ripgrep = "15.1.1" ripgrep = "15.1.0"
uv = "0.11.21" uv = "0.11.21"
golangci-lint = "2.9.0" golangci-lint = "2.9.0"
git-cliff = "2.13.1" git-cliff = "2.13.1"