feat: add reset action for integration watches (#1381)
This commit is contained in:
@@ -504,6 +504,22 @@ func registerRoutes(p routeParams) {
|
|||||||
// runCanceller but its container leaks because the cascade bypasses
|
// 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"})
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Package watchreset orchestrates "reset" for integration watches
|
||||||
|
// (GitHub issue / GitHub review / Jira / Linear / Sentry). A reset is a
|
||||||
|
// destructive operation that:
|
||||||
|
//
|
||||||
|
// 1. Deletes every Kandev task previously created by the watch (cascade,
|
||||||
|
// including archived tasks — "wipe the slate").
|
||||||
|
// 2. Wipes the per-watch dedup rows so the next poll re-considers every
|
||||||
|
// currently-matching external item.
|
||||||
|
// 3. Clears the watch's last_polled_at so it polls immediately.
|
||||||
|
//
|
||||||
|
// Each integration implements the small Resetter interface against its own
|
||||||
|
// store, and calls Run with a *task.HandoffService for the cascade delete.
|
||||||
|
package watchreset
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/kandev/kandev/internal/common/logger"
|
||||||
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resetter is the per-watch surface watchreset needs. Implementations
|
||||||
|
// close over the watch ID and the integration's store. Methods are called
|
||||||
|
// in a fixed order by Run: ListTaskIDs first, then Clear after the cascade
|
||||||
|
// delete loop completes.
|
||||||
|
type Resetter interface {
|
||||||
|
// ListTaskIDs returns every task ID previously created by the watch,
|
||||||
|
// including rows whose task_id is empty (those are skipped by Run but
|
||||||
|
// the dedup row will still be wiped by Clear).
|
||||||
|
ListTaskIDs(ctx context.Context) ([]string, error)
|
||||||
|
// Clear wipes all dedup rows for the watch and nulls its
|
||||||
|
// last_polled_at column. Implementations should do both in a single
|
||||||
|
// transaction so a partial reset cannot leave the watch in a state
|
||||||
|
// where it skips items it just decided to forget about.
|
||||||
|
Clear(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskDeleter is the cascade-delete entry point. Satisfied by
|
||||||
|
// *task.HandoffService.DeleteTaskTree.
|
||||||
|
type TaskDeleter interface {
|
||||||
|
DeleteTaskTree(ctx context.Context, rootID string, cascade bool) (*taskservice.CascadeOutcome, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result reports what Run did. TasksDeleted counts task trees that were
|
||||||
|
// successfully cascade-deleted; tasks that failed to delete are logged but
|
||||||
|
// don't fail the reset (the dedup table is still wiped so the watch
|
||||||
|
// re-imports them on the next poll).
|
||||||
|
type Result struct {
|
||||||
|
TasksDeleted int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a watch reset. Deletes are best-effort: a delete failure
|
||||||
|
// for one task is logged and skipped so the dedup wipe still runs — a
|
||||||
|
// reset that fails halfway would leave the watch unable to re-import the
|
||||||
|
// items it just lost references to. Returns the final Result regardless;
|
||||||
|
// the only fatal error is the dedup/clear step itself.
|
||||||
|
//
|
||||||
|
// Once started, the reset runs to completion even if the caller's context
|
||||||
|
// is cancelled (e.g. the HTTP client disconnects mid-reset). A half-applied
|
||||||
|
// reset is worse than a slow one: the user already confirmed the
|
||||||
|
// destructive action, and the dedup wipe is the step that lets them retry
|
||||||
|
// safely. The cancel signal is preserved for deadlines that arrive
|
||||||
|
// before Run is invoked, but not for ones that fire after.
|
||||||
|
func Run(ctx context.Context, r Resetter, td TaskDeleter, log *logger.Logger) (Result, error) {
|
||||||
|
if r == nil {
|
||||||
|
return Result{}, fmt.Errorf("watchreset: nil Resetter")
|
||||||
|
}
|
||||||
|
if td == nil {
|
||||||
|
return Result{}, fmt.Errorf("watchreset: nil TaskDeleter")
|
||||||
|
}
|
||||||
|
// Respect a caller deadline that's already expired before we detach.
|
||||||
|
// WithoutCancel intentionally drops every signal, so swallowing an
|
||||||
|
// already-cancelled context here would silently run an unwanted reset.
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
ctx = context.WithoutCancel(ctx)
|
||||||
|
|
||||||
|
ids, err := r.ListTaskIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("list watch task ids: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := td.DeleteTaskTree(ctx, id, true); err != nil {
|
||||||
|
if log != nil {
|
||||||
|
log.Warn("watchreset: delete task failed",
|
||||||
|
zap.String("task_id", id), zap.Error(err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.Clear(ctx); err != nil {
|
||||||
|
return Result{TasksDeleted: deleted}, fmt.Errorf("clear watch state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if log != nil {
|
||||||
|
log.Info("watch.reset",
|
||||||
|
zap.Int("tasks_deleted", deleted),
|
||||||
|
zap.Int("tasks_considered", len(ids)))
|
||||||
|
}
|
||||||
|
return Result{TasksDeleted: deleted}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview returns the number of tasks that would be deleted by Run. It
|
||||||
|
// counts non-empty task IDs only — dedup rows whose task creation never
|
||||||
|
// completed don't have a Kandev task to delete.
|
||||||
|
func Preview(ctx context.Context, r Resetter) (int, error) {
|
||||||
|
if r == nil {
|
||||||
|
return 0, fmt.Errorf("watchreset: nil Resetter")
|
||||||
|
}
|
||||||
|
ids, err := r.ListTaskIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("list watch task ids: %w", err)
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
if id != "" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package watchreset
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeResetter struct {
|
||||||
|
ids []string
|
||||||
|
listErr error
|
||||||
|
cleared bool
|
||||||
|
clearErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeResetter) ListTaskIDs(_ context.Context) ([]string, error) {
|
||||||
|
return f.ids, f.listErr
|
||||||
|
}
|
||||||
|
func (f *fakeResetter) Clear(_ context.Context) error {
|
||||||
|
f.cleared = true
|
||||||
|
return f.clearErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeDeleter struct {
|
||||||
|
called []string
|
||||||
|
failOn map[string]error
|
||||||
|
outcome *taskservice.CascadeOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDeleter) DeleteTaskTree(_ context.Context, id string, _ bool) (*taskservice.CascadeOutcome, error) {
|
||||||
|
f.called = append(f.called, id)
|
||||||
|
if err, ok := f.failOn[id]; ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.outcome, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_DeletesEveryNonEmptyIDAndClears(t *testing.T) {
|
||||||
|
r := &fakeResetter{ids: []string{"a", "b", "", "c"}}
|
||||||
|
td := &fakeDeleter{}
|
||||||
|
res, err := Run(context.Background(), r, td, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if res.TasksDeleted != 3 {
|
||||||
|
t.Fatalf("TasksDeleted=%d want 3", res.TasksDeleted)
|
||||||
|
}
|
||||||
|
if got := len(td.called); got != 3 {
|
||||||
|
t.Fatalf("DeleteTaskTree calls=%d want 3", got)
|
||||||
|
}
|
||||||
|
if !r.cleared {
|
||||||
|
t.Fatalf("Clear not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_ContinuesOnDeleteFailure(t *testing.T) {
|
||||||
|
r := &fakeResetter{ids: []string{"ok1", "bad", "ok2"}}
|
||||||
|
td := &fakeDeleter{failOn: map[string]error{"bad": errors.New("boom")}}
|
||||||
|
res, err := Run(context.Background(), r, td, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if res.TasksDeleted != 2 {
|
||||||
|
t.Fatalf("TasksDeleted=%d want 2 (skip failing)", res.TasksDeleted)
|
||||||
|
}
|
||||||
|
if !r.cleared {
|
||||||
|
t.Fatalf("Clear must still run even when a delete failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_PropagatesListError(t *testing.T) {
|
||||||
|
r := &fakeResetter{listErr: errors.New("db down")}
|
||||||
|
td := &fakeDeleter{}
|
||||||
|
_, err := Run(context.Background(), r, td, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected list error to surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_PropagatesClearError(t *testing.T) {
|
||||||
|
r := &fakeResetter{ids: []string{"a"}, clearErr: errors.New("write fail")}
|
||||||
|
td := &fakeDeleter{}
|
||||||
|
res, err := Run(context.Background(), r, td, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected clear error to surface")
|
||||||
|
}
|
||||||
|
if res.TasksDeleted != 1 {
|
||||||
|
t.Fatalf("partial TasksDeleted should still be reported, got %d", res.TasksDeleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreview_CountsOnlyNonEmptyIDs(t *testing.T) {
|
||||||
|
r := &fakeResetter{ids: []string{"", "a", "", "b", "c"}}
|
||||||
|
n, err := Preview(context.Background(), r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Preview: %v", err)
|
||||||
|
}
|
||||||
|
if n != 3 {
|
||||||
|
t.Fatalf("n=%d want 3", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_RejectsNilArgs(t *testing.T) {
|
||||||
|
if _, err := Run(context.Background(), nil, &fakeDeleter{}, nil); err == nil {
|
||||||
|
t.Fatalf("nil Resetter must error")
|
||||||
|
}
|
||||||
|
if _, err := Run(context.Background(), &fakeResetter{}, nil, nil); err == nil {
|
||||||
|
t.Fatalf("nil TaskDeleter must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_RejectsAlreadyCancelledContext pins the ctx.Err() pre-check:
|
||||||
|
// WithoutCancel intentionally drops every cancellation signal, so without
|
||||||
|
// this guard a caller passing an already-expired ctx (deadline missed
|
||||||
|
// before Run was even invoked) would silently get a destructive reset.
|
||||||
|
func TestRun_RejectsAlreadyCancelledContext(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
r := &fakeResetter{ids: []string{"a"}}
|
||||||
|
td := &fakeDeleter{}
|
||||||
|
_, err := Run(ctx, r, td, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for already-cancelled context")
|
||||||
|
}
|
||||||
|
if r.cleared {
|
||||||
|
t.Fatalf("Clear must not run when context is already cancelled")
|
||||||
|
}
|
||||||
|
if len(td.called) != 0 {
|
||||||
|
t.Fatalf("DeleteTaskTree must not run when context is already cancelled, got %d calls", len(td.called))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { PRStatsPanel } from "./pr-stats";
|
|||||||
import { useReviewWatches } from "@/hooks/domains/github/use-review-watches";
|
import { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user