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>
This commit is contained in:
João Salavisa
2026-04-21 17:32:46 +01:00
committed by GitHub
parent dccff23cc0
commit c11a6bbfd2
51 changed files with 3580 additions and 463 deletions
+179 -55
View File
@@ -11,9 +11,12 @@ import { SettingsSection } from "@/components/settings/settings-section";
import { GitHubStatusCard } from "./github-status";
import { ReviewWatchTable } from "./review-watch-table";
import { ReviewWatchDialog } from "./review-watch-dialog";
import { IssueWatchTable } from "./issue-watch-table";
import { IssueWatchDialog } from "./issue-watch-dialog";
import { PRStatsPanel } from "./pr-stats";
import { useReviewWatches } from "@/hooks/domains/github/use-review-watches";
import type { ReviewWatch } from "@/lib/types/github";
import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches";
import type { ReviewWatch, IssueWatch } from "@/lib/types/github";
type GitHubSettingsProps = {
workspaceId: string;
@@ -73,19 +76,61 @@ function useWatchActions(workspaceId: string | null) {
return { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled };
}
export function GitHubSettings({ workspaceId }: GitHubSettingsProps) {
const { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled } =
useWatchActions(workspaceId);
function useIssueWatchActions(workspaceId: string | null) {
const { items: watches, create, update, remove, trigger } = useIssueWatches(workspaceId);
const { toast } = useToast();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingWatch, setEditingWatch] = useState<ReviewWatch | null>(null);
const handleDelete = useCallback(
async (id: string) => {
try {
await remove(id);
toast({ description: "Issue watch deleted", variant: "success" });
} catch {
toast({ description: "Failed to delete issue watch", variant: "error" });
}
},
[remove, toast],
);
const handleEdit = useCallback((watch: ReviewWatch) => {
setEditingWatch(watch);
setDialogOpen(true);
}, []);
const handleTrigger = useCallback(
async (id: string) => {
try {
const result = await trigger(id);
const count = result?.new_issues_found ?? 0;
if (count > 0) {
toast({
description: `Found ${count} new issue${count > 1 ? "s" : ""}`,
variant: "success",
});
} else {
toast({ description: "No new issues found" });
}
} catch {
toast({ description: "Failed to check for issues", variant: "error" });
}
},
[trigger, toast],
);
const handleToggleEnabled = useCallback(
async (watch: IssueWatch) => {
try {
await update(watch.id, { enabled: !watch.enabled });
toast({
description: watch.enabled ? "Watch paused" : "Watch enabled",
variant: "success",
});
} catch {
toast({ description: "Failed to update watch", variant: "error" });
}
},
[update, toast],
);
return { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled };
}
export function GitHubSettings({ workspaceId }: GitHubSettingsProps) {
return (
<TooltipProvider>
<div className="space-y-8">
@@ -95,7 +140,7 @@ export function GitHubSettings({ workspaceId }: GitHubSettingsProps) {
GitHub Integration
</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure GitHub PR monitoring and review queue automation.
Configure GitHub PR monitoring, issue tracking, and review queue automation.
</p>
</div>
@@ -109,35 +154,8 @@ export function GitHubSettings({ workspaceId }: GitHubSettingsProps) {
</Card>
</SettingsSection>
<SettingsSection
title="Review Watches"
description="Automatically create tasks for PRs that need your review."
action={
<Button
size="sm"
onClick={() => {
setEditingWatch(null);
setDialogOpen(true);
}}
className="cursor-pointer"
>
<IconPlus className="h-4 w-4 mr-1" />
Add Watch
</Button>
}
>
<Card>
<CardContent className="p-0">
<ReviewWatchTable
watches={watches}
onEdit={handleEdit}
onDelete={handleDelete}
onTrigger={handleTrigger}
onToggleEnabled={handleToggleEnabled}
/>
</CardContent>
</Card>
</SettingsSection>
<ReviewWatchSection workspaceId={workspaceId} />
<IssueWatchSection workspaceId={workspaceId} />
<SettingsSection
title="PR Analytics"
@@ -145,22 +163,128 @@ export function GitHubSettings({ workspaceId }: GitHubSettingsProps) {
>
<PRStatsPanel workspaceId={workspaceId} />
</SettingsSection>
<ReviewWatchDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
watch={editingWatch}
workspaceId={workspaceId}
onCreate={async (req) => {
await create(req);
toast({ description: "Review watch created", variant: "success" });
}}
onUpdate={async (id, req) => {
await update(id, req);
toast({ description: "Review watch updated", variant: "success" });
}}
/>
</div>
</TooltipProvider>
);
}
function ReviewWatchSection({ workspaceId }: { workspaceId: string }) {
const { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled } =
useWatchActions(workspaceId);
const { toast } = useToast();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingWatch, setEditingWatch] = useState<ReviewWatch | null>(null);
const handleEdit = useCallback((watch: ReviewWatch) => {
setEditingWatch(watch);
setDialogOpen(true);
}, []);
return (
<>
<SettingsSection
title="Review Watches"
description="Automatically create tasks for PRs that need your review."
action={
<Button
size="sm"
onClick={() => {
setEditingWatch(null);
setDialogOpen(true);
}}
className="cursor-pointer"
>
<IconPlus className="h-4 w-4 mr-1" />
Add Watch
</Button>
}
>
<Card>
<CardContent className="p-0">
<ReviewWatchTable
watches={watches}
onEdit={handleEdit}
onDelete={handleDelete}
onTrigger={handleTrigger}
onToggleEnabled={handleToggleEnabled}
/>
</CardContent>
</Card>
</SettingsSection>
<ReviewWatchDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
watch={editingWatch}
workspaceId={workspaceId}
onCreate={async (req) => {
await create(req);
toast({ description: "Review watch created", variant: "success" });
}}
onUpdate={async (id, req) => {
await update(id, req);
toast({ description: "Review watch updated", variant: "success" });
}}
/>
</>
);
}
function IssueWatchSection({ workspaceId }: { workspaceId: string }) {
const issueActions = useIssueWatchActions(workspaceId);
const { toast } = useToast();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingWatch, setEditingIssueWatch] = useState<IssueWatch | null>(null);
const handleEdit = useCallback((watch: IssueWatch) => {
setEditingIssueWatch(watch);
setDialogOpen(true);
}, []);
return (
<>
<SettingsSection
title="Issue Watches"
description="Automatically create tasks for GitHub issues matching your criteria."
action={
<Button
size="sm"
onClick={() => {
setEditingIssueWatch(null);
setDialogOpen(true);
}}
className="cursor-pointer"
>
<IconPlus className="h-4 w-4 mr-1" />
Add Watch
</Button>
}
>
<Card>
<CardContent className="p-0">
<IssueWatchTable
watches={issueActions.watches}
onEdit={handleEdit}
onDelete={issueActions.handleDelete}
onTrigger={issueActions.handleTrigger}
onToggleEnabled={issueActions.handleToggleEnabled}
/>
</CardContent>
</Card>
</SettingsSection>
<IssueWatchDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
watch={editingWatch}
workspaceId={workspaceId}
onCreate={async (req) => {
await issueActions.create(req);
toast({ description: "Issue watch created", variant: "success" });
}}
onUpdate={async (id, req) => {
await issueActions.update(id, req);
toast({ description: "Issue watch updated", variant: "success" });
}}
/>
</>
);
}
@@ -0,0 +1,27 @@
"use client";
import { IconCircleDot } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
type IssueInfo = { url: string; number: number };
export function IssueTaskIcon({ issueInfo }: { issueInfo: IssueInfo }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<a
href={issueInfo.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center shrink-0 text-green-600 hover:text-green-500 cursor-pointer"
data-testid="issue-task-icon"
aria-label={`Issue #${issueInfo.number}`}
>
<IconCircleDot className="h-3.5 w-3.5" />
</a>
</TooltipTrigger>
<TooltipContent>Issue #{issueInfo.number}</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,489 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@kandev/ui/button";
import { Switch } from "@kandev/ui/switch";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@kandev/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Textarea } from "@kandev/ui/textarea";
import { IconInfoCircle } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { useWorkflows } from "@/hooks/use-workflows";
import { listWorkflowSteps } from "@/lib/api/domains/workflow-api";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import {
ISSUE_WATCH_PLACEHOLDERS,
DEFAULT_ISSUE_WATCH_PROMPT,
} from "@/components/github/issue-watch-placeholders";
import { RepoFilterSelector } from "@/components/github/repo-filter-selector";
import type {
RepoFilter,
IssueWatch,
CreateIssueWatchRequest,
UpdateIssueWatchRequest,
} from "@/lib/types/github";
type IssueWatchDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
watch: IssueWatch | null;
workspaceId: string;
onCreate: (req: CreateIssueWatchRequest) => Promise<void>;
onUpdate: (id: string, req: UpdateIssueWatchRequest) => Promise<void>;
};
type FormState = {
selectedRepos: RepoFilter[];
allRepos: boolean;
workflowId: string;
workflowStepId: string;
agentProfileId: string;
executorProfileId: string;
prompt: string;
labels: string;
customQuery: string;
enabled: boolean;
pollInterval: number;
};
type WorkflowStepOption = { id: string; name: string };
const DEFAULT_QUERY = "type:issue state:open";
const defaultFormState: FormState = {
selectedRepos: [],
allRepos: false,
workflowId: "",
workflowStepId: "",
agentProfileId: "",
executorProfileId: "",
prompt: DEFAULT_ISSUE_WATCH_PROMPT,
labels: "",
customQuery: DEFAULT_QUERY,
enabled: true,
pollInterval: 300,
};
function formStateFromWatch(watch: IssueWatch): FormState {
const hasRepos = watch.repos && watch.repos.length > 0;
return {
selectedRepos: hasRepos ? watch.repos : [],
allRepos: !hasRepos,
workflowId: watch.workflow_id,
workflowStepId: watch.workflow_step_id,
agentProfileId: watch.agent_profile_id,
executorProfileId: watch.executor_profile_id,
prompt: watch.prompt || DEFAULT_ISSUE_WATCH_PROMPT,
labels: (watch.labels ?? []).join(", "),
customQuery: watch.custom_query || DEFAULT_QUERY,
enabled: watch.enabled,
pollInterval: watch.poll_interval_seconds,
};
}
function useWorkflowSteps(workflowId: string) {
const [steps, setSteps] = useState<WorkflowStepOption[]>([]);
useEffect(() => {
if (!workflowId) {
void Promise.resolve().then(() => setSteps([]));
return;
}
let cancelled = false;
listWorkflowSteps(workflowId)
.then((response) => {
if (cancelled) return;
const sorted = [...response.steps].sort((a, b) => a.position - b.position);
setSteps(sorted.map((s) => ({ id: s.id, name: s.name })));
})
.catch(() => {
if (!cancelled) setSteps([]);
});
return () => {
cancelled = true;
};
}, [workflowId]);
return steps;
}
function useWatchFormData(workspaceId: string) {
useSettingsData(true);
useWorkflows(workspaceId, true);
const workflows = useAppStore((state) => state.workflows.items);
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
const executors = useAppStore((state) => state.executors.items);
const allExecutorProfiles = useMemo(
() => executors.filter((e) => e.type !== "local").flatMap((e) => e.profiles ?? []),
[executors],
);
const filteredAgentProfiles = useMemo(
() => agentProfiles.filter((p) => !p.cli_passthrough),
[agentProfiles],
);
return { workflows, agentProfiles: filteredAgentProfiles, allExecutorProfiles };
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 pt-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider shrink-0">
{children}
</span>
<div className="flex-1 border-t border-border" />
</div>
);
}
function PlaceholdersHelp() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground/50 hover:text-muted-foreground cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent className="max-w-xs" align="start">
<p className="text-xs font-medium mb-1">Available placeholders:</p>
<ul className="text-xs space-y-0.5">
{ISSUE_WATCH_PLACEHOLDERS.map((p) => (
<li key={p.key}>
<code className="text-[10px] bg-white/15 px-1 rounded">{`{{${p.key}}}`}</code>{" "}
<span className="opacity-70">{p.description}</span>
</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function IssueWatchFormFields({
form,
setForm,
workspaceId,
onAllReposChange,
onSelectedReposChange,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
workspaceId: string;
onAllReposChange: (checked: boolean) => void;
onSelectedReposChange: (repos: RepoFilter[]) => void;
}) {
return (
<div className="space-y-5">
<IssueFilterFields
form={form}
setForm={setForm}
onAllReposChange={onAllReposChange}
onSelectedReposChange={onSelectedReposChange}
/>
<IssueAutomationFields form={form} setForm={setForm} workspaceId={workspaceId} />
<IssueSettingsFields form={form} setForm={setForm} />
</div>
);
}
function IssueFilterFields({
form,
setForm,
onAllReposChange,
onSelectedReposChange,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
onAllReposChange: (checked: boolean) => void;
onSelectedReposChange: (repos: RepoFilter[]) => void;
}) {
return (
<>
<SectionHeader>Filter</SectionHeader>
<RepoFilterSelector
allRepos={form.allRepos}
selectedRepos={form.selectedRepos}
onAllReposChange={onAllReposChange}
onSelectedReposChange={onSelectedReposChange}
/>
<div className="space-y-1.5">
<Label>Labels (comma-separated)</Label>
<Input
value={form.labels}
onChange={(e) => setForm((prev) => ({ ...prev, labels: e.target.value }))}
placeholder="e.g. bug, enhancement, priority:high"
/>
<p className="text-xs text-muted-foreground">
Only match issues with these labels. Leave empty for all issues.
</p>
</div>
<div className="space-y-1.5">
<Label>Custom Query</Label>
<Textarea
value={form.customQuery}
onChange={(e) => setForm((prev) => ({ ...prev, customQuery: e.target.value }))}
placeholder="e.g. type:issue state:open label:bug"
rows={1}
className="font-mono text-xs resize-y"
/>
<p className="text-xs text-muted-foreground">
GitHub search query. When set, overrides the label filter above.
</p>
</div>
</>
);
}
function IssueAutomationFields({
form,
setForm,
workspaceId,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
workspaceId: string;
}) {
const { workflows, agentProfiles, allExecutorProfiles } = useWatchFormData(workspaceId);
const workflowSteps = useWorkflowSteps(form.workflowId);
return (
<>
<SectionHeader>Automation</SectionHeader>
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Workflow"
description="The workflow to create tasks in."
value={form.workflowId}
onChange={(v) => setForm((prev) => ({ ...prev, workflowId: v, workflowStepId: "" }))}
placeholder="Select workflow"
items={workflows.map((w) => ({ id: w.id, label: w.name }))}
/>
<SelectField
label="Workflow Step"
description="Initial step for new tasks."
value={form.workflowStepId}
onChange={(v) => setForm((prev) => ({ ...prev, workflowStepId: v }))}
placeholder={form.workflowId ? "Select step" : "Select a workflow first"}
items={workflowSteps.map((s) => ({ id: s.id, label: s.name }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Agent Profile"
description="The agent configuration for the task."
value={form.agentProfileId}
onChange={(v) => setForm((prev) => ({ ...prev, agentProfileId: v }))}
placeholder="Select agent profile"
items={agentProfiles.map((p) => ({ id: p.id, label: p.label }))}
/>
<SelectField
label="Executor Profile"
description="The executor environment for the agent."
value={form.executorProfileId}
onChange={(v) => setForm((prev) => ({ ...prev, executorProfileId: v }))}
placeholder="Select executor profile"
items={allExecutorProfiles.map((p) => ({ id: p.id, label: p.name }))}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label>Task Prompt</Label>
<PlaceholdersHelp />
</div>
<p className="text-xs text-muted-foreground">
The prompt sent to the agent for each new issue. Type {"{{"} to insert placeholders.
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
value={form.prompt}
onChange={(v) => setForm((prev) => ({ ...prev, prompt: v }))}
language="markdown"
height={computeEditorHeight(form.prompt)}
lineNumbers="off"
placeholders={ISSUE_WATCH_PLACEHOLDERS}
/>
</div>
</div>
</>
);
}
function IssueSettingsFields({
form,
setForm,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
}) {
return (
<>
<SectionHeader>Settings</SectionHeader>
<div className="space-y-1.5">
<Label>Poll Interval (seconds)</Label>
<p className="text-xs text-muted-foreground">
How often to check for new issues. Minimum 60s, maximum 3600s.
</p>
<Input
type="number"
value={form.pollInterval}
onChange={(e) => setForm((prev) => ({ ...prev, pollInterval: Number(e.target.value) }))}
min={60}
max={3600}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Enabled</Label>
<p className="text-xs text-muted-foreground">Pause or resume polling.</p>
</div>
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm((prev) => ({ ...prev, enabled: v }))}
className="cursor-pointer"
/>
</div>
</>
);
}
function parseLabels(labelsStr: string): string[] {
return labelsStr
.split(",")
.map((l) => l.trim())
.filter((l) => l.length > 0);
}
function getSaveLabel(watch: IssueWatch | null | undefined): string {
return watch ? "Update" : "Create";
}
export function IssueWatchDialog({
open,
onOpenChange,
watch,
workspaceId,
onCreate,
onUpdate,
}: IssueWatchDialogProps) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>(defaultFormState);
useEffect(() => {
setForm(watch ? formStateFromWatch(watch) : defaultFormState);
}, [watch, open]);
const handleSelectedReposChange = useCallback((repos: RepoFilter[]) => {
setForm((prev) => ({ ...prev, selectedRepos: repos }));
}, []);
const handleAllReposChange = useCallback((checked: boolean) => {
setForm((prev) => ({
...prev,
allRepos: checked,
selectedRepos: checked ? [] : prev.selectedRepos,
}));
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
try {
const repos = form.allRepos ? [] : form.selectedRepos;
const payload = {
workflow_id: form.workflowId,
workflow_step_id: form.workflowStepId,
repos,
agent_profile_id: form.agentProfileId,
executor_profile_id: form.executorProfileId,
prompt: form.prompt,
labels: parseLabels(form.labels),
custom_query: form.customQuery,
enabled: form.enabled,
poll_interval_seconds: form.pollInterval,
};
if (watch) {
await onUpdate(watch.id, payload);
} else {
await onCreate({ ...payload, workspace_id: workspaceId });
}
onOpenChange(false);
} catch {
// Error handled by caller
} finally {
setSaving(false);
}
}, [form, watch, workspaceId, onCreate, onUpdate, onOpenChange]);
const canSave = !!form.workflowId && !!form.workflowStepId && form.prompt.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-full sm:w-[900px] sm:max-w-none max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{watch ? "Edit Issue Watch" : "Create Issue Watch"}</DialogTitle>
<DialogDescription>
Automatically create tasks when new GitHub issues match your criteria.
</DialogDescription>
</DialogHeader>
<IssueWatchFormFields
form={form}
setForm={setForm}
workspaceId={workspaceId}
onAllReposChange={handleAllReposChange}
onSelectedReposChange={handleSelectedReposChange}
/>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !canSave} className="cursor-pointer">
{saving ? "Saving..." : getSaveLabel(watch)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function SelectField(props: {
label: string;
description?: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
items: { id: string; label: string }[];
}) {
return (
<div className="space-y-1.5">
<Label>{props.label}</Label>
{props.description && <p className="text-xs text-muted-foreground">{props.description}</p>}
<Select value={props.value || undefined} onValueChange={props.onChange}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent>
{props.items.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -0,0 +1,63 @@
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
export const ISSUE_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [
{
key: "issue.link",
description: "Issue URL",
example: "https://github.com/org/repo/issues/42",
executor_types: [],
},
{
key: "issue.number",
description: "Issue number",
example: "42",
executor_types: [],
},
{
key: "issue.title",
description: "Issue title",
example: "Fix login page crash",
executor_types: [],
},
{
key: "issue.author",
description: "Issue author username",
example: "octocat",
executor_types: [],
},
{
key: "issue.repo",
description: "Repository (owner/name)",
example: "org/repo",
executor_types: [],
},
{
key: "issue.labels",
description: "Comma-separated labels",
example: "bug, priority:high",
executor_types: [],
},
{
key: "issue.body",
description: "Issue body text",
example: "When clicking login...",
executor_types: [],
},
];
export const DEFAULT_ISSUE_WATCH_PROMPT = `You have been assigned a GitHub issue to work on.
**Issue:** {{issue.link}}
**Title:** {{issue.title}} (#{{issue.number}})
**Repository:** {{issue.repo}}
**Author:** {{issue.author}}
**Labels:** {{issue.labels}}
## Instructions
1. Read the issue description carefully and understand the requirements.
2. Explore the codebase to understand the relevant code and architecture.
3. Implement the changes described in the issue.
4. Write or update tests to cover the changes.
5. Run the test suite to ensure nothing is broken.
6. Commit your changes with a descriptive commit message referencing the issue.`;
@@ -0,0 +1,168 @@
"use client";
import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useToast } from "@/components/toast-provider";
import type { IssueWatch } from "@/lib/types/github";
type IssueWatchTableProps = {
watches: IssueWatch[];
onEdit: (watch: IssueWatch) => void;
onDelete: (id: string) => void;
onTrigger: (id: string) => void;
onToggleEnabled: (watch: IssueWatch) => void;
};
function formatLastPolled(dateStr: string | null): string {
if (!dateStr) return "Never";
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function formatRepoNames(watch: IssueWatch): string {
if (watch.repos.length === 0) return "All repositories";
return watch.repos
.map((r) => (r.name === "" ? `${r.owner}/*` : `${r.owner}/${r.name}`))
.join(", ");
}
function formatLabels(watch: IssueWatch): string {
if (!watch.labels || watch.labels.length === 0) return "";
return watch.labels.join(", ");
}
type WatchActionsProps = {
watch: IssueWatch;
onToggleEnabled: (watch: IssueWatch) => void;
onTrigger: (id: string) => void;
onDelete: (id: string) => void;
};
function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActionsProps) {
const { toast } = useToast();
return (
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onToggleEnabled(watch);
}}
>
{watch.enabled ? (
<IconPlayerPause className="h-3.5 w-3.5" />
) : (
<IconPlayerPlay className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{watch.enabled ? "Pause" : "Enable"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onTrigger(watch.id);
toast({ description: "Checking for new issues..." });
}}
>
<IconRefresh className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Check now</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete(watch.id);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
);
}
export function IssueWatchTable({
watches,
onEdit,
onDelete,
onTrigger,
onToggleEnabled,
}: IssueWatchTableProps) {
if (watches.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
No issue watches configured. Create one to start monitoring GitHub issues.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Labels</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Last Polled</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{watches.map((watch) => (
<TableRow key={watch.id} className="cursor-pointer" onClick={() => onEdit(watch)}>
<TableCell className="font-medium">{formatRepoNames(watch)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatLabels(watch) || "—"}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{Math.round(watch.poll_interval_seconds / 60)}m
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatLastPolled(watch.last_polled_at)}
</TableCell>
<TableCell>
<Badge variant={watch.enabled ? "default" : "secondary"} className="text-xs">
{watch.enabled ? "Active" : "Paused"}
</Badge>
</TableCell>
<TableCell className="text-right">
<WatchActions
watch={watch}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onDelete={onDelete}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}