Files
kandev/apps/web/lib/api/domains/github-api.ts
T
João Salavisa c11a6bbfd2 feat(github): add issue watcher with task creation and auto-cleanup (#672)
* 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>
2026-04-21 17:32:46 +01:00

245 lines
7.4 KiB
TypeScript

import { fetchJson, type ApiRequestOptions } from "../client";
import type {
GitHubStatusResponse,
GitHubOrg,
GitHubRepoInfo,
GitHubPR,
TaskPRsResponse,
TaskPR,
PRFeedback,
PRWatchesResponse,
ReviewWatch,
ReviewWatchesResponse,
CreateReviewWatchRequest,
UpdateReviewWatchRequest,
TriggerReviewResponse,
PRStatsResponse,
IssueWatch,
IssueWatchesResponse,
CreateIssueWatchRequest,
UpdateIssueWatchRequest,
TriggerIssueResponse,
} from "@/lib/types/github";
// Status
export async function fetchGitHubStatus(options?: ApiRequestOptions) {
return fetchJson<GitHubStatusResponse>("/api/v1/github/status", options);
}
// Task PR associations
export async function listTaskPRs(taskIds: string[], options?: ApiRequestOptions) {
const query = new URLSearchParams();
query.set("task_ids", taskIds.join(","));
return fetchJson<TaskPRsResponse>(`/api/v1/github/task-prs?${query.toString()}`, options);
}
export async function listWorkspaceTaskPRs(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<TaskPRsResponse>(
`/api/v1/github/task-prs?workspace_id=${encodeURIComponent(workspaceId)}`,
options,
);
}
export async function getTaskPR(taskId: string, options?: ApiRequestOptions) {
return fetchJson<TaskPR>(`/api/v1/github/task-prs/${taskId}`, options);
}
// PR feedback (live from GitHub)
export async function getPRFeedback(
owner: string,
repo: string,
number: number,
options?: ApiRequestOptions,
) {
return fetchJson<PRFeedback>(`/api/v1/github/prs/${owner}/${repo}/${number}`, options);
}
// Submit PR review
export async function submitPRReview(
owner: string,
repo: string,
number: number,
event: "APPROVE" | "COMMENT" | "REQUEST_CHANGES",
body?: string,
) {
return fetchJson<{ submitted: boolean }>(
`/api/v1/github/prs/${owner}/${repo}/${number}/reviews`,
{
init: {
method: "POST",
body: JSON.stringify({ event, body: body ?? "" }),
},
},
);
}
// PR watches
export async function listPRWatches(options?: ApiRequestOptions) {
return fetchJson<PRWatchesResponse>("/api/v1/github/watches/pr", options);
}
export async function deletePRWatch(id: string, options?: ApiRequestOptions) {
return fetchJson<{ success: boolean }>(`/api/v1/github/watches/pr/${id}`, {
...options,
init: { method: "DELETE", ...(options?.init ?? {}) },
});
}
// Review watches
export async function listReviewWatches(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<ReviewWatchesResponse>(
`/api/v1/github/watches/review?${query.toString()}`,
options,
);
}
export async function createReviewWatch(
payload: CreateReviewWatchRequest,
options?: ApiRequestOptions,
) {
return fetchJson<ReviewWatch>("/api/v1/github/watches/review", {
...options,
init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
export async function updateReviewWatch(
id: string,
payload: UpdateReviewWatchRequest,
options?: ApiRequestOptions,
) {
return fetchJson<ReviewWatch>(`/api/v1/github/watches/review/${id}`, {
...options,
init: { method: "PUT", body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
export async function deleteReviewWatch(id: string, options?: ApiRequestOptions) {
return fetchJson<{ success: boolean }>(`/api/v1/github/watches/review/${id}`, {
...options,
init: { method: "DELETE", ...(options?.init ?? {}) },
});
}
export async function triggerReviewWatch(id: string, options?: ApiRequestOptions) {
return fetchJson<TriggerReviewResponse>(`/api/v1/github/watches/review/${id}/trigger`, {
...options,
init: { method: "POST", ...(options?.init ?? {}) },
});
}
export async function triggerAllReviewWatches(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<TriggerReviewResponse>(
`/api/v1/github/watches/review/trigger-all?${query.toString()}`,
{
...options,
init: { method: "POST", ...(options?.init ?? {}) },
},
);
}
// Orgs & repo search
export async function listUserOrgs(options?: ApiRequestOptions) {
return fetchJson<{ orgs: GitHubOrg[] }>("/api/v1/github/orgs", options);
}
export async function searchOrgRepos(org: string, query?: string, options?: ApiRequestOptions) {
const params = new URLSearchParams({ org });
if (query) params.set("q", query);
return fetchJson<{ repos: GitHubRepoInfo[] }>(
`/api/v1/github/repos/search?${params.toString()}`,
options,
);
}
// PR info (lightweight)
export async function fetchPRInfo(
owner: string,
repo: string,
number: number,
options?: ApiRequestOptions,
) {
return fetchJson<GitHubPR>(
`/api/v1/github/prs/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/info`,
options,
);
}
// Remote repo branches
export async function fetchRepoBranches(owner: string, repo: string, options?: ApiRequestOptions) {
return fetchJson<{ branches: { name: string }[] }>(
`/api/v1/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`,
options,
);
}
// Stats
export async function fetchGitHubStats(
params?: { workspace_id?: string; start_date?: string; end_date?: string },
options?: ApiRequestOptions,
) {
const query = new URLSearchParams();
if (params?.workspace_id) query.set("workspace_id", params.workspace_id);
if (params?.start_date) query.set("start_date", params.start_date);
if (params?.end_date) query.set("end_date", params.end_date);
const suffix = query.toString();
return fetchJson<PRStatsResponse>(`/api/v1/github/stats${suffix ? `?${suffix}` : ""}`, options);
}
// Issue watches
export async function listIssueWatches(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<IssueWatchesResponse>(
`/api/v1/github/watches/issue?${query.toString()}`,
options,
);
}
export async function createIssueWatch(
payload: CreateIssueWatchRequest,
options?: ApiRequestOptions,
) {
return fetchJson<IssueWatch>("/api/v1/github/watches/issue", {
...options,
init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
export async function updateIssueWatch(
id: string,
payload: UpdateIssueWatchRequest,
options?: ApiRequestOptions,
) {
return fetchJson<IssueWatch>(`/api/v1/github/watches/issue/${id}`, {
...options,
init: { method: "PUT", body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
export async function deleteIssueWatch(id: string, options?: ApiRequestOptions) {
return fetchJson<{ deleted: boolean }>(`/api/v1/github/watches/issue/${id}`, {
...options,
init: { method: "DELETE", ...(options?.init ?? {}) },
});
}
export async function triggerIssueWatch(id: string, options?: ApiRequestOptions) {
return fetchJson<TriggerIssueResponse>(`/api/v1/github/watches/issue/${id}/trigger`, {
...options,
init: { method: "POST", ...(options?.init ?? {}) },
});
}
export async function triggerAllIssueWatches(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<TriggerIssueResponse>(
`/api/v1/github/watches/issue/trigger-all?${query.toString()}`,
{
...options,
init: { method: "POST", ...(options?.init ?? {}) },
},
);
}