c11a6bbfd2
* feat(github): add issue watcher with task creation and auto-cleanup
Add a GitHub Issue Watcher feature that polls for new issues matching
configurable filters and automatically creates tasks for them. This
complements the existing PR Review Watcher with the same patterns.
Backend:
- Issue watch CRUD (models, store, service, controller, WS handlers)
- Poller integration with configurable intervals
- Event-driven task creation via orchestrator
- Repository resolution and worktree support for issue tasks
- Auto-cleanup of tasks for closed issues (CleanupClosedIssueTasks)
- GetIssueState client method across all implementations
- Default branch auto-detection from cloned repo HEAD
Frontend:
- Issue watch settings UI (table, dialog, placeholders)
- Issue link icon on task cards (green circle-dot)
- API client, store slice, and hook for issue watches
- SSR and WS hydration of issue metadata
* fix: resolve golangci-lint errors (goconst, nestif, dupl) and prettier formatting
* fix(github): fix issue watch update shape, trigger key, and label quoting
- httpUpdateIssueWatch now returns the full IssueWatch object instead of
{updated:true} so the frontend store correctly refreshes after edits
- httpTriggerIssueWatch returns new_issues_found to match TriggerIssueResponse
type; was returning new_issues so the toast count was always 0
- buildIssueFilter uses conditional quoting instead of fmt.Sprintf %q to
avoid Go escape sequences in GitHub search qualifiers
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
* fix: resolve eslint warnings (max-lines-per-function, no-nested-ternary)
* test(github): add issue watcher tests and fix review feedback
- Add TestInterpolateIssuePrompt (4 cases: empty/all/none/partial)
- Add TestCreateIssueTask (3 cases: skip/reserve+assign/release on fail)
- Add TestShouldDeleteIssueTask (4 cases: open/closed+sessions/closed/error)
- Fix WS response key new_issues -> new_issues_found
- Fix fmt.Printf -> fmt.Fprintf(os.Stderr) in store.go
- Add CleanupClosedIssueTasks call in WS trigger handler
* fix: address PR review feedback and CI failures
- Split test files to fix revive file-length-limit (service_test.go,
event_handlers_github_test.go)
- Add session checker guard in CleanupClosedIssueTasks
- Normalize poll intervals on UpdateIssueWatch
- Add cleanup call in TriggerAllIssueChecks loop
- Log cleanup errors in controller and WS handler
- Prefer refs/remotes/origin/HEAD in detectGitDefaultBranch
- Add aria-label to issue task icon
- Fix TypeScript type errors: add linkedIssues to GitHubSlice,
fix hook exports, remove invalid imports
* fix: split test files exceeding revive limit and clean up minor issues
- Split service_test.go (883→779 lines): move TestTriggerPRSync_* to
service_pr_sync_test.go
- Split event_handlers_github_test.go (806→677 lines): move
TestCreateReviewTask_* and helpers to event_handlers_github_review_test.go
- Remove redundant s.taskSessionChecker nil-check inside shouldDeleteIssueTask
(early guard at CleanupClosedIssueTasks already ensures it is non-nil)
- Remove unreachable dead guard in detectAndPersistDefaultBranch
(repo.DefaultBranch is always "" at that call site)
- Fix bogus #0 issue badge in task-session-sidebar.tsx when issueNumber is absent
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
* fix: address Claude review — poll clamp and prompt injection
- Handle v <= 0 in UpdateIssueWatch poll interval (fall back to default)
- Remove {{issue.body}} from default template to prevent prompt injection
- Update test expected output for new default template
* fix: remove {{issue.body}} from frontend DEFAULT_ISSUE_WATCH_PROMPT
Mirrors the backend fix — the UI pre-fills the prompt so the backend
fallback was never reached. The placeholder stays in
ISSUE_WATCH_PLACEHOLDERS for explicit opt-in.
* feat(github): add isIssueWatch sidebar filter dimension
Add isIssueWatch boolean to KanbanState, TaskSwitcherItem, and sidebar
filter registry — mirroring the isPRReview pattern. Issue-watch tasks
(created from issue_watch_id in metadata) can now be explicitly filtered
in the sidebar, just like PR review tasks.
Files changed:
- sidebar-view-types: add isIssueWatch dimension
- filter-dimension-registry: add Issue watch filter meta
- apply-view: add isIssueWatch extractor
- kanban types: add isIssueWatch field
- TaskSwitcherItem: add isIssueWatch field
- use-all-workflow-snapshots: derive isIssueWatch from metadata
- ssr/mapper: derive isIssueWatch from metadata
- task-session-sidebar: pass isIssueWatch, extract helpers to fix complexity
* fix: correct deleteIssueWatch response type to match backend
Backend httpDeleteIssueWatch returns {"deleted": true}; the frontend
type was { success: boolean } — a mismatch with no functional impact
(return value is ignored) but wrong per TypeScript contract.
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
* refactor: extract metadata helpers to shared lib/metadata-utils.ts
- Move isPRReviewFromMetadata, isIssueWatchFromMetadata, and
issueFieldsFromMetadata to a shared module to eliminate duplicate
definitions (sonarjs/no-identical-functions).
- Fix deleteIssueWatch response type: { success: boolean } -> { deleted: boolean }
to match the actual backend response.
* style: fix prettier formatting
* test(web): add tests for isIssueWatch filter dimension and metadata-utils
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* style: fix prettier formatting in test files
* fix(github): cascade-delete issue watch tasks on watch deletion and add isPRReview to SSR mapper
- DeleteIssueWatch now wraps in a transaction and deletes related
github_issue_watch_tasks rows before removing the watch; prevents
orphaned dedup rows accumulating after a watch is deleted
- snapshotToState in mapper.ts now also sets isPRReview from metadata,
matching the mapping already done in use-all-workflow-snapshots.ts
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
* style: extract duplicate string constant in metadata-utils test
* style: extract repeated WS error strings into constants in handlers.go
goconst min-occurrences: 3 — 'invalid payload' (6x) and 'id required' (4x)
both exceed the threshold. Extract to package-level constants.
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
* style: extract repeated issue key strings into constants in event_handlers_github.go
goconst min-occurrences: 3 — 'issue_number' (9x) and 'issue_watch_id' (5x)
both newly introduced in this PR exceed the threshold.
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
* style: fix prettier formatting in metadata-utils test
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
350 lines
9.5 KiB
Go
350 lines
9.5 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const mockDefaultUser = "mock-user"
|
|
|
|
// prKey is a composite key for PR lookups by owner/repo/number.
|
|
type prKey struct {
|
|
Owner string
|
|
Repo string
|
|
Number int
|
|
}
|
|
|
|
// branchKey is a composite key for PR lookups by owner/repo/branch.
|
|
type branchKey struct {
|
|
Owner string
|
|
Repo string
|
|
Branch string
|
|
}
|
|
|
|
// checkKey is a composite key for check-run lookups by owner/repo/ref.
|
|
type checkKey struct {
|
|
Owner string
|
|
Repo string
|
|
Ref string
|
|
}
|
|
|
|
// submittedReview records a SubmitReview call for test assertions.
|
|
type submittedReview struct {
|
|
Owner string `json:"owner"`
|
|
Repo string `json:"repo"`
|
|
Number int `json:"number"`
|
|
Event string `json:"event"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// repoKey is a composite key for per-repo lookups by owner/repo.
|
|
type repoKey struct {
|
|
Owner string
|
|
Repo string
|
|
}
|
|
|
|
// MockClient implements Client with in-memory configurable data for E2E testing.
|
|
// All data is protected by a sync.RWMutex for thread safety.
|
|
type MockClient struct {
|
|
mu sync.RWMutex
|
|
user string
|
|
prs map[prKey]*PR
|
|
prsByBranch map[branchKey]*PR
|
|
orgs []GitHubOrg
|
|
repos map[string][]GitHubRepo
|
|
branches map[repoKey][]RepoBranch
|
|
reviews map[prKey][]PRReview
|
|
comments map[prKey][]PRComment
|
|
checks map[checkKey][]CheckRun
|
|
files map[prKey][]PRFile
|
|
commits map[prKey][]PRCommitInfo
|
|
submittedReviews []submittedReview
|
|
}
|
|
|
|
// NewMockClient creates a new MockClient with default values.
|
|
func NewMockClient() *MockClient {
|
|
return &MockClient{
|
|
user: mockDefaultUser,
|
|
prs: make(map[prKey]*PR),
|
|
prsByBranch: make(map[branchKey]*PR),
|
|
repos: make(map[string][]GitHubRepo),
|
|
branches: make(map[repoKey][]RepoBranch),
|
|
reviews: make(map[prKey][]PRReview),
|
|
comments: make(map[prKey][]PRComment),
|
|
checks: make(map[checkKey][]CheckRun),
|
|
files: make(map[prKey][]PRFile),
|
|
commits: make(map[prKey][]PRCommitInfo),
|
|
}
|
|
}
|
|
|
|
// --- Client interface implementation ---
|
|
|
|
func (m *MockClient) IsAuthenticated(context.Context) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (m *MockClient) GetAuthenticatedUser(context.Context) (string, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.user, nil
|
|
}
|
|
|
|
func (m *MockClient) GetPR(_ context.Context, owner, repo string, number int) (*PR, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
pr, ok := m.prs[prKey{owner, repo, number}]
|
|
if !ok {
|
|
return nil, fmt.Errorf("mock: PR %s/%s#%d not found", owner, repo, number)
|
|
}
|
|
return pr, nil
|
|
}
|
|
|
|
func (m *MockClient) FindPRByBranch(_ context.Context, owner, repo, branch string) (*PR, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
pr := m.prsByBranch[branchKey{owner, repo, branch}]
|
|
return pr, nil
|
|
}
|
|
|
|
func (m *MockClient) ListAuthoredPRs(_ context.Context, owner, repo string) ([]*PR, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var result []*PR
|
|
for k, pr := range m.prs {
|
|
if k.Owner == owner && k.Repo == repo && pr.AuthorLogin == m.user {
|
|
result = append(result, pr)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *MockClient) ListReviewRequestedPRs(context.Context, string, string, string) ([]*PR, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var result []*PR
|
|
for _, pr := range m.prs {
|
|
if len(pr.RequestedReviewers) > 0 {
|
|
result = append(result, pr)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *MockClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockClient) GetIssueState(context.Context, string, string, int) (string, error) {
|
|
return defaultPRState, nil
|
|
}
|
|
|
|
func (m *MockClient) ListUserOrgs(context.Context) ([]GitHubOrg, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if m.orgs == nil {
|
|
return []GitHubOrg{}, nil
|
|
}
|
|
return m.orgs, nil
|
|
}
|
|
|
|
func (m *MockClient) SearchOrgRepos(_ context.Context, org, query string, _ int) ([]GitHubRepo, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
repos := m.repos[org]
|
|
if query == "" {
|
|
return repos, nil
|
|
}
|
|
var filtered []GitHubRepo
|
|
for _, r := range repos {
|
|
if strings.Contains(strings.ToLower(r.FullName), strings.ToLower(query)) {
|
|
filtered = append(filtered, r)
|
|
}
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func (m *MockClient) ListPRReviews(_ context.Context, owner, repo string, number int) ([]PRReview, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.reviews[prKey{owner, repo, number}], nil
|
|
}
|
|
|
|
func (m *MockClient) ListPRComments(_ context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
all := m.comments[prKey{owner, repo, number}]
|
|
if since == nil {
|
|
return all, nil
|
|
}
|
|
var filtered []PRComment
|
|
for _, c := range all {
|
|
if c.UpdatedAt.After(*since) {
|
|
filtered = append(filtered, c)
|
|
}
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func (m *MockClient) ListCheckRuns(_ context.Context, owner, repo, ref string) ([]CheckRun, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.checks[checkKey{owner, repo, ref}], nil
|
|
}
|
|
|
|
func (m *MockClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) {
|
|
return getPRFeedback(ctx, m, owner, repo, number)
|
|
}
|
|
|
|
func (m *MockClient) GetPRStatus(ctx context.Context, owner, repo string, number int) (*PRStatus, error) {
|
|
return getPRStatus(ctx, m, owner, repo, number)
|
|
}
|
|
|
|
func (m *MockClient) ListPRFiles(_ context.Context, owner, repo string, number int) ([]PRFile, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.files[prKey{owner, repo, number}], nil
|
|
}
|
|
|
|
func (m *MockClient) ListPRCommits(_ context.Context, owner, repo string, number int) ([]PRCommitInfo, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.commits[prKey{owner, repo, number}], nil
|
|
}
|
|
|
|
func (m *MockClient) ListRepoBranches(_ context.Context, owner, repo string) ([]RepoBranch, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
branches, ok := m.branches[repoKey{owner, repo}]
|
|
if !ok {
|
|
return nil, &GitHubAPIError{
|
|
StatusCode: 404,
|
|
Endpoint: fmt.Sprintf("/repos/%s/%s/branches", owner, repo),
|
|
Body: fmt.Sprintf("repository %s/%s not found", owner, repo),
|
|
}
|
|
}
|
|
out := make([]RepoBranch, len(branches))
|
|
copy(out, branches)
|
|
return out, nil
|
|
}
|
|
|
|
func (m *MockClient) SubmitReview(_ context.Context, owner, repo string, number int, event, body string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.submittedReviews = append(m.submittedReviews, submittedReview{
|
|
Owner: owner, Repo: repo, Number: number, Event: event, Body: body,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// --- Setter methods for HTTP control endpoints ---
|
|
|
|
// SetUser sets the authenticated username.
|
|
func (m *MockClient) SetUser(username string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.user = username
|
|
}
|
|
|
|
// AddPR adds a PR to the mock data store, indexed by owner/repo/number and branch.
|
|
func (m *MockClient) AddPR(pr *PR) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.prs[prKey{pr.RepoOwner, pr.RepoName, pr.Number}] = pr
|
|
if pr.HeadBranch != "" {
|
|
m.prsByBranch[branchKey{pr.RepoOwner, pr.RepoName, pr.HeadBranch}] = pr
|
|
}
|
|
}
|
|
|
|
// AddOrgs appends organizations to the mock data store.
|
|
func (m *MockClient) AddOrgs(orgs []GitHubOrg) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.orgs = append(m.orgs, orgs...)
|
|
}
|
|
|
|
// AddBranches sets branches for a repository.
|
|
func (m *MockClient) AddBranches(owner, repo string, branches []RepoBranch) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
cp := make([]RepoBranch, len(branches))
|
|
copy(cp, branches)
|
|
m.branches[repoKey{owner, repo}] = cp
|
|
}
|
|
|
|
// AddRepos appends repositories for an organization.
|
|
func (m *MockClient) AddRepos(org string, repos []GitHubRepo) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.repos[org] = append(m.repos[org], repos...)
|
|
}
|
|
|
|
// AddReviews appends reviews for a PR.
|
|
func (m *MockClient) AddReviews(owner, repo string, number int, reviews []PRReview) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
k := prKey{owner, repo, number}
|
|
m.reviews[k] = append(m.reviews[k], reviews...)
|
|
}
|
|
|
|
// AddComments appends comments for a PR.
|
|
func (m *MockClient) AddComments(owner, repo string, number int, comments []PRComment) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
k := prKey{owner, repo, number}
|
|
m.comments[k] = append(m.comments[k], comments...)
|
|
}
|
|
|
|
// AddCheckRuns appends check runs for a ref.
|
|
func (m *MockClient) AddCheckRuns(owner, repo, ref string, checks []CheckRun) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
k := checkKey{owner, repo, ref}
|
|
m.checks[k] = append(m.checks[k], checks...)
|
|
}
|
|
|
|
// AddPRFiles appends files for a PR.
|
|
func (m *MockClient) AddPRFiles(owner, repo string, number int, files []PRFile) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
k := prKey{owner, repo, number}
|
|
m.files[k] = append(m.files[k], files...)
|
|
}
|
|
|
|
// AddPRCommits appends commits for a PR.
|
|
func (m *MockClient) AddPRCommits(owner, repo string, number int, commits []PRCommitInfo) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
k := prKey{owner, repo, number}
|
|
m.commits[k] = append(m.commits[k], commits...)
|
|
}
|
|
|
|
// Reset clears all mock data and resets the user to the default.
|
|
func (m *MockClient) Reset() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.user = mockDefaultUser
|
|
m.prs = make(map[prKey]*PR)
|
|
m.prsByBranch = make(map[branchKey]*PR)
|
|
m.orgs = nil
|
|
m.repos = make(map[string][]GitHubRepo)
|
|
m.branches = make(map[repoKey][]RepoBranch)
|
|
m.reviews = make(map[prKey][]PRReview)
|
|
m.comments = make(map[prKey][]PRComment)
|
|
m.checks = make(map[checkKey][]CheckRun)
|
|
m.files = make(map[prKey][]PRFile)
|
|
m.commits = make(map[prKey][]PRCommitInfo)
|
|
m.submittedReviews = nil
|
|
}
|
|
|
|
// SubmittedReviews returns all recorded SubmitReview calls.
|
|
func (m *MockClient) SubmittedReviews() []submittedReview {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
result := make([]submittedReview, len(m.submittedReviews))
|
|
copy(result, m.submittedReviews)
|
|
return result
|
|
}
|