Files
kandev/apps/backend/internal/github/service_task_events.go
T
José Almeida 375472cc02 fix(github): stop PR polling for archived tasks (#643)
* fix(github): stop PR polling for archived tasks

The PR watcher kept making GitHub API calls for tasks the user had
archived, wasting rate limit and surfacing stale checks. Hide archived
and orphaned watches from the poller via an INNER JOIN on tasks, and
proactively prune watches when task.updated (archived_at set) or
task.deleted events fire. Closes #989.

* fix(github): address review comments on PR watch pruning

- Detect archived_at from a non-empty string value instead of key
  presence, so a future null/zero archived_at in the task event payload
  doesn't silently prune watches on non-archive updates.
- Track task-event subscriptions on the Service and release them in the
  provider cleanup so handlers don't accumulate if the service is torn
  down and re-created while the event bus persists.
- Propagate RowsAffected errors from DeletePRWatchesByTaskID instead of
  silently discarding them.
2026-04-19 21:49:05 +01:00

99 lines
3.2 KiB
Go

package github
import (
"context"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
// subscribeTaskEvents wires the github service to task archive/delete events so
// PR watches are pruned proactively. The ListActivePRWatches query already hides
// archived-task watches from the poller, but pruning keeps the table bounded.
// Subscriptions are tracked so they can be released via unsubscribeTaskEvents.
func (s *Service) subscribeTaskEvents() {
if s.eventBus == nil {
return
}
if sub, err := s.eventBus.Subscribe(events.TaskUpdated, s.handleTaskUpdated); err != nil {
s.logger.Error("failed to subscribe to task.updated events", zap.Error(err))
} else {
s.taskEventSubs = append(s.taskEventSubs, sub)
}
if sub, err := s.eventBus.Subscribe(events.TaskDeleted, s.handleTaskDeleted); err != nil {
s.logger.Error("failed to subscribe to task.deleted events", zap.Error(err))
} else {
s.taskEventSubs = append(s.taskEventSubs, sub)
}
}
// unsubscribeTaskEvents releases the subscriptions created in subscribeTaskEvents.
// Called from the provider cleanup so handlers don't accumulate if the service is
// torn down and re-created while the event bus persists.
func (s *Service) unsubscribeTaskEvents() {
for _, sub := range s.taskEventSubs {
if err := sub.Unsubscribe(); err != nil {
s.logger.Error("failed to unsubscribe from task event", zap.Error(err))
}
}
s.taskEventSubs = nil
}
// handleTaskUpdated deletes PR watches when a task is archived. Non-archive
// updates are ignored so we don't interfere with normal task edits.
func (s *Service) handleTaskUpdated(ctx context.Context, event *bus.Event) error {
taskID, archived := taskIDAndArchivedFrom(event)
if taskID == "" || !archived {
return nil
}
s.pruneWatchesForTask(ctx, taskID, "archived")
return nil
}
// handleTaskDeleted deletes PR watches when a task is hard-deleted.
func (s *Service) handleTaskDeleted(ctx context.Context, event *bus.Event) error {
taskID, _ := taskIDAndArchivedFrom(event)
if taskID == "" {
return nil
}
s.pruneWatchesForTask(ctx, taskID, "deleted")
return nil
}
func (s *Service) pruneWatchesForTask(ctx context.Context, taskID, reason string) {
n, err := s.store.DeletePRWatchesByTaskID(ctx, taskID)
if err != nil {
s.logger.Error("failed to delete PR watches for task",
zap.String("task_id", taskID),
zap.String("reason", reason),
zap.Error(err))
return
}
if n > 0 {
s.logger.Info("pruned PR watches after task change",
zap.String("task_id", taskID),
zap.String("reason", reason),
zap.Int64("deleted", n))
}
}
// taskIDAndArchivedFrom extracts task_id + archived flag from a task event
// payload (task-service publishes a map[string]interface{} — see
// internal/task/service/service_events.go publishTaskEvent). Archived is
// detected from a non-empty string value so a future null/zero archived_at
// in the payload doesn't silently prune watches on non-archive updates.
func taskIDAndArchivedFrom(event *bus.Event) (taskID string, archived bool) {
if event == nil {
return "", false
}
data, ok := event.Data.(map[string]interface{})
if !ok {
return "", false
}
id, _ := data["task_id"].(string)
archivedStr, _ := data["archived_at"].(string)
return id, archivedStr != ""
}