feat(github): configurable quick-action presets and PR branch checkout (#689)

* feat(github): configurable quick-action presets and PR branch checkout

- Add workspace-scoped action presets (icon, label, hint, prompt template) for PRs and Issues, editable from GitHub settings with reset-to-defaults.
- When starting a task from a PR, check out the PR head branch directly in the worktree instead of branching off main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(github): extract action-preset service to keep service.go under line limit

Splits the preset CRUD methods out of the oversized service.go and applies prettier formatting to three frontend files flagged by CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(github): address review feedback on action presets

- Prevent infinite refetch loop when fetching presets fails by tracking attempted workspaces in a ref instead of re-entering the effect on every loading-state change.
- Drop unused savePR/saveIssue helpers from the hook; only save() is consumed.
- Reorder the init spread in updateGitHubActionPresets/resetGitHubActionPresets so callers can't accidentally overwrite method/body.
- Tighten the GitHubActionPreset.icon type with string & {} so known icon keys keep autocomplete.
- Sharpen the eye-icon test to assert the exact IconEye mapping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(github): address second round of review feedback

- Seed preset editor drafts from current store state via lazy initializers so a warm mount shows the user's saved customisations instead of silently reverting to defaults when Save is clicked.
- Guard Reset button with its own in-flight state to prevent double-firing.
- Stop freezing resolved defaults into the stored row on a partial update; persist only what the store has for the untouched kind so future default improvements still reach those workspaces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(github): tighten quick-actions editor UI

Replace the stacked PR/Issue editors and multi-line preset cards with a tabbed layout and single-line rows. Each row shows icon · label · hint inline; an "Edit prompt" toggle reveals the template textarea only when needed. Drops the redundant section headings, up/down reorder buttons, and verbose template helper label (hint moved into the textarea placeholder).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(github): widen icon select trigger so the icon is visible

The icon select trigger had a fixed 40px width which the SelectTrigger's built-in chevron, padding, and icon gap all competed for, clipping the icon out of view. Drop the width override so the trigger sizes to its content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-04-24 14:10:59 +01:00
committed by GitHub
parent 44dec297e1
commit ccc62d9324
25 changed files with 1264 additions and 76 deletions
@@ -0,0 +1,96 @@
package github
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
)
// GetActionPresets returns the stored preset lists for a workspace, falling
// back to the built-in defaults for any list that hasn't been customised yet.
func (s *Service) GetActionPresets(ctx context.Context, workspaceID string) (*ActionPresets, error) {
stored, err := s.store.GetActionPresets(ctx, workspaceID)
if err != nil {
return nil, err
}
result := &ActionPresets{
WorkspaceID: workspaceID,
PR: DefaultPRActionPresets(),
Issue: DefaultIssueActionPresets(),
}
if stored == nil {
return result, nil
}
if len(stored.PR) > 0 {
result.PR = stored.PR
}
if len(stored.Issue) > 0 {
result.Issue = stored.Issue
}
return result, nil
}
// UpdateActionPresets replaces the PR or Issue preset lists for a workspace.
// Nil request fields leave that list untouched. For any kind not in the
// request, we persist whatever the store currently has (possibly empty)
// rather than the resolved defaults — otherwise future default improvements
// would never reach workspaces that have only customised one side.
func (s *Service) UpdateActionPresets(ctx context.Context, req *UpdateActionPresetsRequest) (*ActionPresets, error) {
if req == nil || req.WorkspaceID == "" {
return nil, fmt.Errorf("workspace_id is required")
}
stored, err := s.store.GetActionPresets(ctx, req.WorkspaceID)
if err != nil {
return nil, err
}
next := &ActionPresets{WorkspaceID: req.WorkspaceID}
if stored != nil {
next.PR = stored.PR
next.Issue = stored.Issue
}
if req.PR != nil {
next.PR = normalisePresets(*req.PR)
}
if req.Issue != nil {
next.Issue = normalisePresets(*req.Issue)
}
if err := s.store.UpsertActionPresets(ctx, next); err != nil {
return nil, err
}
return s.GetActionPresets(ctx, req.WorkspaceID)
}
// ResetActionPresets drops any stored overrides so the defaults apply again.
func (s *Service) ResetActionPresets(ctx context.Context, workspaceID string) (*ActionPresets, error) {
if workspaceID == "" {
return nil, fmt.Errorf("workspace_id is required")
}
if err := s.store.DeleteActionPresets(ctx, workspaceID); err != nil {
return nil, err
}
return s.GetActionPresets(ctx, workspaceID)
}
func normalisePresets(presets []ActionPreset) []ActionPreset {
out := make([]ActionPreset, 0, len(presets))
for _, p := range presets {
id := strings.TrimSpace(p.ID)
label := strings.TrimSpace(p.Label)
if label == "" {
continue
}
if id == "" {
id = uuid.New().String()
}
out = append(out, ActionPreset{
ID: id,
Label: label,
Hint: strings.TrimSpace(p.Hint),
Icon: strings.TrimSpace(p.Icon),
PromptTemplate: p.PromptTemplate,
})
}
return out
}
@@ -0,0 +1,155 @@
package github
import (
"context"
"testing"
)
// TestGetActionPresets_EmptyReturnsDefaults verifies that a workspace with no
// stored presets gets the hard-coded defaults — the /github page should never
// see an empty dropdown for a fresh workspace.
func TestGetActionPresets_EmptyReturnsDefaults(t *testing.T) {
svc, _, _ := setupSyncTest(t)
ctx := context.Background()
got, err := svc.GetActionPresets(ctx, "ws-1")
if err != nil {
t.Fatalf("GetActionPresets: %v", err)
}
if got.WorkspaceID != "ws-1" {
t.Errorf("WorkspaceID = %q, want %q", got.WorkspaceID, "ws-1")
}
if len(got.PR) != len(DefaultPRActionPresets()) {
t.Errorf("PR preset count = %d, want %d", len(got.PR), len(DefaultPRActionPresets()))
}
if len(got.Issue) != len(DefaultIssueActionPresets()) {
t.Errorf("Issue preset count = %d, want %d", len(got.Issue), len(DefaultIssueActionPresets()))
}
}
// TestUpdateActionPresets_PartialUpdate verifies that updating only the PR
// list leaves the Issue list at defaults, and vice versa.
func TestUpdateActionPresets_PartialUpdate(t *testing.T) {
svc, _, _ := setupSyncTest(t)
ctx := context.Background()
custom := []ActionPreset{
{ID: "custom", Label: "Custom review", Hint: "Deep review", Icon: "eye", PromptTemplate: "Review {url}"},
}
got, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
WorkspaceID: "ws-1",
PR: &custom,
})
if err != nil {
t.Fatalf("UpdateActionPresets: %v", err)
}
if len(got.PR) != 1 || got.PR[0].Label != "Custom review" {
t.Errorf("unexpected PR presets: %+v", got.PR)
}
if len(got.Issue) != len(DefaultIssueActionPresets()) {
t.Errorf("Issue presets not preserved at defaults: %+v", got.Issue)
}
// Re-read to make sure it persisted.
reread, err := svc.GetActionPresets(ctx, "ws-1")
if err != nil {
t.Fatalf("GetActionPresets after update: %v", err)
}
if len(reread.PR) != 1 || reread.PR[0].Label != "Custom review" {
t.Errorf("stored PR presets lost: %+v", reread.PR)
}
}
// TestUpdateActionPresets_NormalisesInput verifies that empty-label presets
// are dropped and missing IDs get auto-assigned.
func TestUpdateActionPresets_NormalisesInput(t *testing.T) {
svc, _, _ := setupSyncTest(t)
ctx := context.Background()
input := []ActionPreset{
{ID: "", Label: " Has label ", PromptTemplate: "go"},
{ID: "skip", Label: "", PromptTemplate: "drop me"},
}
got, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
WorkspaceID: "ws-1",
PR: &input,
})
if err != nil {
t.Fatalf("UpdateActionPresets: %v", err)
}
if len(got.PR) != 1 {
t.Fatalf("empty-label preset not dropped: %+v", got.PR)
}
if got.PR[0].Label != "Has label" {
t.Errorf("label not trimmed: %q", got.PR[0].Label)
}
if got.PR[0].ID == "" {
t.Errorf("missing ID not auto-assigned")
}
}
// TestResetActionPresets_RestoresDefaults verifies that reset removes stored
// customisations — the next Get returns defaults again.
func TestResetActionPresets_RestoresDefaults(t *testing.T) {
svc, _, _ := setupSyncTest(t)
ctx := context.Background()
custom := []ActionPreset{
{ID: "custom", Label: "Custom", PromptTemplate: "x"},
}
if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
WorkspaceID: "ws-1",
PR: &custom,
}); err != nil {
t.Fatalf("UpdateActionPresets: %v", err)
}
got, err := svc.ResetActionPresets(ctx, "ws-1")
if err != nil {
t.Fatalf("ResetActionPresets: %v", err)
}
if len(got.PR) != len(DefaultPRActionPresets()) {
t.Errorf("reset did not restore PR defaults: got %d", len(got.PR))
}
}
// TestUpdateActionPresets_RequiresWorkspaceID guards the controller-layer
// invariant: the service refuses an empty workspace rather than writing
// rows keyed on "".
func TestUpdateActionPresets_RequiresWorkspaceID(t *testing.T) {
svc, _, _ := setupSyncTest(t)
ctx := context.Background()
empty := []ActionPreset{{ID: "x", Label: "x"}}
if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{PR: &empty}); err == nil {
t.Fatal("expected error on empty workspace_id, got nil")
}
}
// TestGetActionPresets_FallsBackPerKind verifies the half-populated case: if
// PR was customised but Issue wasn't, Issue falls back to defaults.
func TestGetActionPresets_FallsBackPerKind(t *testing.T) {
svc, _, _ := setupSyncTest(t)
ctx := context.Background()
custom := []ActionPreset{
{ID: "only-pr", Label: "Only PR", PromptTemplate: "pr"},
}
if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
WorkspaceID: "ws-1",
PR: &custom,
}); err != nil {
t.Fatalf("UpdateActionPresets: %v", err)
}
got, err := svc.GetActionPresets(ctx, "ws-1")
if err != nil {
t.Fatalf("GetActionPresets: %v", err)
}
if len(got.PR) != 1 {
t.Errorf("PR not the customised list: %+v", got.PR)
}
if len(got.Issue) == 0 {
t.Errorf("Issue list empty — expected fallback to defaults")
}
}
@@ -62,6 +62,10 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api.GET("/user/prs", c.httpSearchUserPRs)
api.GET("/user/issues", c.httpSearchUserIssues)
api.GET("/action-presets", c.httpGetActionPresets)
api.PUT("/action-presets", c.httpUpdateActionPresets)
api.POST("/action-presets/reset", c.httpResetActionPresets)
api.GET("/stats", c.httpGetStats)
}
@@ -600,3 +604,51 @@ func (c *Controller) httpTriggerAllIssueChecks(ctx *gin.Context) {
}
ctx.JSON(http.StatusOK, gin.H{"new_issues_found": count})
}
// --- Action preset HTTP handlers ---
func (c *Controller) httpGetActionPresets(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
return
}
presets, err := c.service.GetActionPresets(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, presets)
}
func (c *Controller) httpUpdateActionPresets(ctx *gin.Context) {
var req UpdateActionPresetsRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if req.WorkspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"})
return
}
presets, err := c.service.UpdateActionPresets(ctx.Request.Context(), &req)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, presets)
}
func (c *Controller) httpResetActionPresets(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
return
}
presets, err := c.service.ResetActionPresets(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, presets)
}
+36
View File
@@ -60,6 +60,11 @@ func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Log
dispatcher.RegisterFunc(ws.ActionGitHubIssueWatchDelete, wsDeleteIssueWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitHubIssueTrigger, wsTriggerIssueWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitHubIssueTriggerAll, wsTriggerAllIssueChecks(svc, log))
// Action preset handlers
dispatcher.RegisterFunc(ws.ActionGitHubActionPresetsList, wsListActionPresets(svc))
dispatcher.RegisterFunc(ws.ActionGitHubActionPresetsUpdate, wsUpdateActionPresets(svc))
dispatcher.RegisterFunc(ws.ActionGitHubActionPresetsReset, wsResetActionPresets(svc))
}
// parseMap parses the WS message payload into a map for simple field lookups.
@@ -440,3 +445,34 @@ func wsTriggerIssueWatch(svc *Service, log *logger.Logger) func(ctx context.Cont
func wsTriggerAllIssueChecks(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return wsTriggerAllByWorkspace("new_issues_found", svc.TriggerAllIssueChecks)
}
// --- Action preset WS handlers ---
func wsListActionPresets(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return wsWithField("workspace_id", func(ctx context.Context, workspaceID string) (interface{}, error) {
return svc.GetActionPresets(ctx, workspaceID)
})
}
func wsUpdateActionPresets(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req UpdateActionPresetsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, errMsgInvalidPayload, nil)
}
if strings.TrimSpace(req.WorkspaceID) == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id required", nil)
}
presets, err := svc.UpdateActionPresets(ctx, &req)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, presets)
}
}
func wsResetActionPresets(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return wsWithField("workspace_id", func(ctx context.Context, workspaceID string) (interface{}, error) {
return svc.ResetActionPresets(ctx, workspaceID)
})
}
+90
View File
@@ -430,3 +430,93 @@ type UpdateIssueWatchRequest struct {
Enabled *bool `json:"enabled,omitempty"`
PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"`
}
// --- Action presets (quick-launch prompts on the /github page) ---
// ActionPresetKind enumerates the two lists of quick-launch presets.
const (
ActionPresetKindPR = "pr"
ActionPresetKindIssue = "issue"
)
// ActionPreset is a single configurable quick-task launcher entry.
// PromptTemplate supports `{url}` and `{title}` placeholders which are
// substituted client-side when the dialog is opened.
type ActionPreset struct {
ID string `json:"id"`
Label string `json:"label"`
Hint string `json:"hint"`
Icon string `json:"icon"`
PromptTemplate string `json:"prompt_template"`
}
// ActionPresets groups the PR and Issue preset lists for a workspace.
type ActionPresets struct {
WorkspaceID string `json:"workspace_id"`
PR []ActionPreset `json:"pr"`
Issue []ActionPreset `json:"issue"`
}
// UpdateActionPresetsRequest replaces one or both preset lists for a workspace.
// Nil fields are left unchanged.
type UpdateActionPresetsRequest struct {
WorkspaceID string `json:"workspace_id"`
PR *[]ActionPreset `json:"pr,omitempty"`
Issue *[]ActionPreset `json:"issue,omitempty"`
}
// DefaultPRActionPresets returns the built-in PR presets used when a workspace
// has no stored overrides.
func DefaultPRActionPresets() []ActionPreset {
return []ActionPreset{
{
ID: "review",
Label: "Review",
Hint: "Read the diff, flag issues",
Icon: "eye",
PromptTemplate: "Review the pull request at {url}. Provide feedback on code quality, correctness, and suggest improvements.",
},
{
ID: "address_feedback",
Label: "Address feedback",
Hint: "Apply review comments",
Icon: "message",
PromptTemplate: "Address the review feedback on the pull request at {url}. Make the requested changes and push them.",
},
{
ID: "fix_ci",
Label: "Fix CI",
Hint: "Diagnose failing checks",
Icon: "tool",
PromptTemplate: "Investigate and fix the CI failures on the pull request at {url}. Run the failing checks locally, diagnose, and push fixes.",
},
}
}
// DefaultIssueActionPresets returns the built-in Issue presets used when a
// workspace has no stored overrides.
func DefaultIssueActionPresets() []ActionPreset {
return []ActionPreset{
{
ID: "implement",
Label: "Implement",
Hint: "Build and open a PR",
Icon: "code",
PromptTemplate: `Implement the changes described in the GitHub issue at {url} (title: "{title}"). Open a pull request when complete.`,
},
{
ID: "investigate",
Label: "Investigate",
Hint: "Find the root cause",
Icon: "search",
PromptTemplate: `Investigate the GitHub issue at {url} (title: "{title}"). Identify root cause and summarize findings.`,
},
{
ID: "reproduce",
Label: "Reproduce",
Hint: "Document repro steps",
Icon: "bug",
PromptTemplate: `Reproduce the bug described in the GitHub issue at {url} (title: "{title}"). Document the reproduction steps.`,
},
}
}
+63
View File
@@ -133,6 +133,13 @@ const createTablesSQL = `
created_at DATETIME NOT NULL,
UNIQUE(issue_watch_id, repo_owner, repo_name, issue_number)
);
CREATE TABLE IF NOT EXISTS github_action_presets (
workspace_id TEXT PRIMARY KEY,
pr_presets TEXT NOT NULL DEFAULT '[]',
issue_presets TEXT NOT NULL DEFAULT '[]',
updated_at DATETIME NOT NULL
);
`
func (s *Store) initSchema() error {
@@ -888,3 +895,59 @@ func (s *Store) DeleteIssueWatchTask(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM github_issue_watch_tasks WHERE id = ?`, id)
return err
}
// --- Action preset operations ---
// GetActionPresets returns stored PR/Issue presets for a workspace. Returns
// (nil, nil) when no row exists yet so the caller can apply defaults.
func (s *Store) GetActionPresets(ctx context.Context, workspaceID string) (*ActionPresets, error) {
var row struct {
PRJSON string `db:"pr_presets"`
IssueJSON string `db:"issue_presets"`
}
err := s.ro.GetContext(ctx, &row,
`SELECT pr_presets, issue_presets FROM github_action_presets WHERE workspace_id = ?`, workspaceID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
presets := &ActionPresets{WorkspaceID: workspaceID}
if err := json.Unmarshal([]byte(row.PRJSON), &presets.PR); err != nil {
return nil, fmt.Errorf("unmarshal pr presets: %w", err)
}
if err := json.Unmarshal([]byte(row.IssueJSON), &presets.Issue); err != nil {
return nil, fmt.Errorf("unmarshal issue presets: %w", err)
}
return presets, nil
}
// UpsertActionPresets stores PR/Issue presets for a workspace, replacing any
// existing row.
func (s *Store) UpsertActionPresets(ctx context.Context, presets *ActionPresets) error {
prJSON, err := json.Marshal(presets.PR)
if err != nil {
return fmt.Errorf("marshal pr presets: %w", err)
}
issueJSON, err := json.Marshal(presets.Issue)
if err != nil {
return fmt.Errorf("marshal issue presets: %w", err)
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO github_action_presets (workspace_id, pr_presets, issue_presets, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(workspace_id) DO UPDATE SET
pr_presets = excluded.pr_presets,
issue_presets = excluded.issue_presets,
updated_at = excluded.updated_at`,
presets.WorkspaceID, string(prJSON), string(issueJSON), time.Now().UTC())
return err
}
// DeleteActionPresets removes the stored overrides for a workspace so defaults
// apply again.
func (s *Store) DeleteActionPresets(ctx context.Context, workspaceID string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM github_action_presets WHERE workspace_id = ?`, workspaceID)
return err
}
+5
View File
@@ -377,6 +377,11 @@ const (
ActionGitHubIssueTrigger = "github.issue_watches.trigger"
ActionGitHubIssueTriggerAll = "github.issue_watches.trigger_all"
ActionGitHubNewIssueNotify = "github.new_issue.notify" // Notification
// Action preset actions for the /github page quick-launch prompts.
ActionGitHubActionPresetsList = "github.action_presets.list"
ActionGitHubActionPresetsUpdate = "github.action_presets.update"
ActionGitHubActionPresetsReset = "github.action_presets.reset"
)
// Error codes
+28 -1
View File
@@ -24,7 +24,13 @@ import { SavePresetDialog } from "@/components/github/my-github/save-preset-dial
import {
QuickTaskLauncher,
type LaunchPayload,
type TaskPreset,
} from "@/components/github/my-github/quick-task-launcher";
import {
resolvePRPresets,
resolveIssuePresets,
} from "@/components/github/my-github/action-presets";
import { useGitHubActionPresets } from "@/hooks/domains/github/use-github-action-presets";
type GitHubPageClientProps = {
workspaceId?: string;
@@ -91,12 +97,16 @@ function ResultsList({
items,
loading,
error,
prPresets,
issuePresets,
onStartTask,
}: {
selection: SidebarSelection;
items: Array<GitHubPR | GitHubIssue>;
loading: boolean;
error: string | null;
prPresets: TaskPreset[];
issuePresets: TaskPreset[];
onStartTask: (payload: LaunchPayload) => void;
}) {
if (selection.kind === "pr") {
@@ -105,6 +115,7 @@ function ResultsList({
items={items as GitHubPR[]}
loading={loading}
error={error}
presets={prPresets}
onStartTask={onStartTask}
/>
);
@@ -114,6 +125,7 @@ function ResultsList({
items={items as GitHubIssue[]}
loading={loading}
error={error}
presets={issuePresets}
onStartTask={onStartTask}
/>
);
@@ -245,10 +257,14 @@ function useGitHubPageState() {
function AuthenticatedLayout({
workspaceId,
state,
prPresets,
issuePresets,
onStartTask,
}: {
workspaceId: string | undefined;
state: GitHubPageState;
prPresets: TaskPreset[];
issuePresets: TaskPreset[];
onStartTask: (payload: LaunchPayload) => void;
}) {
const { selection, search, repoOptions, title } = state;
@@ -286,6 +302,8 @@ function AuthenticatedLayout({
items={search.items}
loading={search.loading}
error={search.error}
prPresets={prPresets}
issuePresets={issuePresets}
onStartTask={onStartTask}
/>
</div>
@@ -309,6 +327,9 @@ export function GitHubPageClient({
const { status, loaded } = useGitHubStatus();
const [launchPayload, setLaunchPayload] = useState<LaunchPayload | null>(null);
const state = useGitHubPageState();
const { presets: storedPresets } = useGitHubActionPresets(workspaceId ?? null);
const prPresets = useMemo(() => resolvePRPresets(storedPresets), [storedPresets]);
const issuePresets = useMemo(() => resolveIssuePresets(storedPresets), [storedPresets]);
// Drop the module-level repo accumulator on page unmount so a later visit
// doesn't inherit a stale set from the previous navigation.
@@ -328,7 +349,13 @@ export function GitHubPageClient({
</div>
)}
{loaded && authed && (
<AuthenticatedLayout workspaceId={workspaceId} state={state} onStartTask={onStartTask} />
<AuthenticatedLayout
workspaceId={workspaceId}
state={state}
prPresets={prPresets}
issuePresets={issuePresets}
onStartTask={onStartTask}
/>
)}
<QuickTaskLauncher
workspaceId={workspaceId ?? null}
@@ -0,0 +1,298 @@
"use client";
import { createElement, useMemo, useState, useCallback } from "react";
import { IconPlus, IconTrash, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@kandev/ui/tabs";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { useGitHubActionPresets } from "@/hooks/domains/github/use-github-action-presets";
import {
DEFAULT_ISSUE_PRESETS,
DEFAULT_PR_PRESETS,
PRESET_ICON_CHOICES,
iconForPresetKey,
} from "@/components/github/my-github/action-presets";
import type { GitHubActionPreset } from "@/lib/types/github";
function newPreset(): GitHubActionPreset {
return {
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New action",
hint: "",
icon: "sparkle",
prompt_template: "",
};
}
function PresetIconSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 cursor-pointer" aria-label="Icon">
<SelectValue>
{createElement(iconForPresetKey(value), { className: "h-4 w-4" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PRESET_ICON_CHOICES.map((choice) => {
const ChoiceIcon = choice.icon;
return (
<SelectItem key={choice.key} value={choice.key} className="cursor-pointer">
<span className="flex items-center gap-2">
<ChoiceIcon className="h-3.5 w-3.5" />
{choice.label}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
function PresetRow({
preset,
expanded,
onToggle,
onPatch,
onRemove,
}: {
preset: GitHubActionPreset;
expanded: boolean;
onToggle: () => void;
onPatch: (patch: Partial<GitHubActionPreset>) => void;
onRemove: () => void;
}) {
return (
<div className="rounded-md border">
<div className="flex items-center gap-2 p-2">
<PresetIconSelect value={preset.icon} onChange={(v) => onPatch({ icon: v })} />
<Input
className="h-8 w-40"
value={preset.label}
placeholder="Label"
onChange={(e) => onPatch({ label: e.target.value })}
/>
<Input
className="h-8 flex-1"
value={preset.hint}
placeholder="Hint (optional)"
onChange={(e) => onPatch({ hint: e.target.value })}
/>
<Button variant="ghost" size="sm" className="h-8 cursor-pointer text-xs" onClick={onToggle}>
{expanded ? "Hide prompt" : "Edit prompt"}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-destructive"
onClick={onRemove}
aria-label="Remove"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
{expanded && (
<div className="px-2 pb-2">
<Textarea
rows={3}
className="text-xs font-mono"
placeholder="Prompt template — {url} and {title} are substituted"
value={preset.prompt_template}
onChange={(e) => onPatch({ prompt_template: e.target.value })}
/>
</div>
)}
</div>
);
}
function PresetEditor({
presets,
onChange,
addLabel,
}: {
presets: GitHubActionPreset[];
onChange: (presets: GitHubActionPreset[]) => void;
addLabel: string;
}) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const patch = useCallback(
(index: number, change: Partial<GitHubActionPreset>) => {
onChange(presets.map((p, i) => (i === index ? { ...p, ...change } : p)));
},
[presets, onChange],
);
const remove = useCallback(
(index: number) => {
onChange(presets.filter((_, i) => i !== index));
},
[presets, onChange],
);
const add = useCallback(() => {
const created = newPreset();
onChange([...presets, created]);
setExpandedId(created.id);
}, [presets, onChange]);
return (
<div className="space-y-2">
{presets.map((preset, index) => (
<PresetRow
key={preset.id}
preset={preset}
expanded={expandedId === preset.id}
onToggle={() => setExpandedId((id) => (id === preset.id ? null : preset.id))}
onPatch={(p) => patch(index, p)}
onRemove={() => remove(index)}
/>
))}
<Button size="sm" variant="outline" onClick={add} className="cursor-pointer">
<IconPlus className="h-3.5 w-3.5 mr-1" />
{addLabel}
</Button>
</div>
);
}
function usePresetDrafts(workspaceId: string): {
prDraft: GitHubActionPreset[];
issueDraft: GitHubActionPreset[];
setPrDraft: (next: GitHubActionPreset[]) => void;
setIssueDraft: (next: GitHubActionPreset[]) => void;
dirty: boolean;
save: () => Promise<void>;
reset: () => Promise<void>;
loading: boolean;
} {
const { presets, save, reset, loading } = useGitHubActionPresets(workspaceId);
const [prDraft, setPrDraft] = useState<GitHubActionPreset[]>(() =>
presets?.pr?.length ? presets.pr : DEFAULT_PR_PRESETS,
);
const [issueDraft, setIssueDraft] = useState<GitHubActionPreset[]>(() =>
presets?.issue?.length ? presets.issue : DEFAULT_ISSUE_PRESETS,
);
// Render-time conditional setState is React's documented "adjust state
// during render" pattern; it resets drafts whenever a new server response
// replaces the presets reference.
const [syncedPresets, setSyncedPresets] = useState(presets);
if (presets && presets !== syncedPresets) {
setSyncedPresets(presets);
setPrDraft(presets.pr?.length ? presets.pr : DEFAULT_PR_PRESETS);
setIssueDraft(presets.issue?.length ? presets.issue : DEFAULT_ISSUE_PRESETS);
}
const dirty = useMemo(() => {
const currentPR = presets?.pr ?? DEFAULT_PR_PRESETS;
const currentIssue = presets?.issue ?? DEFAULT_ISSUE_PRESETS;
return (
JSON.stringify(currentPR) !== JSON.stringify(prDraft) ||
JSON.stringify(currentIssue) !== JSON.stringify(issueDraft)
);
}, [presets, prDraft, issueDraft]);
const persist = useCallback(async () => {
await save({ pr: prDraft, issue: issueDraft });
}, [save, prDraft, issueDraft]);
const doReset = useCallback(async () => {
const response = await reset();
if (response) {
setPrDraft(response.pr?.length ? response.pr : DEFAULT_PR_PRESETS);
setIssueDraft(response.issue?.length ? response.issue : DEFAULT_ISSUE_PRESETS);
}
}, [reset]);
return {
prDraft,
issueDraft,
setPrDraft,
setIssueDraft,
dirty,
save: persist,
reset: doReset,
loading,
};
}
export function ActionPresetsSection({ workspaceId }: { workspaceId: string }) {
const { toast } = useToast();
const { prDraft, issueDraft, setPrDraft, setIssueDraft, dirty, save, reset, loading } =
usePresetDrafts(workspaceId);
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await save();
toast({ description: "Quick actions saved", variant: "success" });
} catch {
toast({ description: "Failed to save quick actions", variant: "error" });
} finally {
setSaving(false);
}
};
const handleReset = async () => {
setResetting(true);
try {
await reset();
toast({ description: "Quick actions reset to defaults", variant: "success" });
} catch {
toast({ description: "Failed to reset quick actions", variant: "error" });
} finally {
setResetting(false);
}
};
return (
<SettingsSection
title="Quick actions"
description="Prompts shown on /github when starting a task from a PR or issue."
action={
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleReset}
disabled={loading || saving || resetting}
className="cursor-pointer"
>
<IconRefresh className="h-3.5 w-3.5 mr-1" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || saving}
className="cursor-pointer"
>
Save changes
</Button>
</div>
}
>
<Tabs defaultValue="pr">
<TabsList>
<TabsTrigger value="pr" className="cursor-pointer">
Pull requests
</TabsTrigger>
<TabsTrigger value="issue" className="cursor-pointer">
Issues
</TabsTrigger>
</TabsList>
<TabsContent value="pr">
<PresetEditor presets={prDraft} onChange={setPrDraft} addLabel="Add PR action" />
</TabsContent>
<TabsContent value="issue">
<PresetEditor presets={issueDraft} onChange={setIssueDraft} addLabel="Add issue action" />
</TabsContent>
</Tabs>
</SettingsSection>
);
}
@@ -13,6 +13,7 @@ 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 { ActionPresetsSection } from "./action-presets-section";
import { PRStatsPanel } from "./pr-stats";
import { useReviewWatches } from "@/hooks/domains/github/use-review-watches";
import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches";
@@ -156,6 +157,7 @@ export function GitHubSettings({ workspaceId }: GitHubSettingsProps) {
<ReviewWatchSection workspaceId={workspaceId} />
<IssueWatchSection workspaceId={workspaceId} />
<ActionPresetsSection workspaceId={workspaceId} />
<SettingsSection
title="PR Analytics"
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_ISSUE_PRESETS,
DEFAULT_PR_PRESETS,
iconForPresetKey,
interpolatePromptTemplate,
resolveIssuePresets,
resolvePRPresets,
toTaskPreset,
} from "./action-presets";
import { IconEye, IconSparkles } from "@tabler/icons-react";
describe("interpolatePromptTemplate", () => {
it("replaces {url} and {title} placeholders", () => {
const out = interpolatePromptTemplate("Review {url} titled {title}", {
url: "https://gh/pr/1",
title: "Fix bug",
});
expect(out).toBe("Review https://gh/pr/1 titled Fix bug");
});
it("leaves unknown placeholders intact", () => {
const out = interpolatePromptTemplate("See {url} and {unknown}", {
url: "u",
title: "t",
});
expect(out).toBe("See u and {unknown}");
});
it("handles a template with no placeholders", () => {
expect(interpolatePromptTemplate("nothing to sub", { url: "u", title: "t" })).toBe(
"nothing to sub",
);
});
});
describe("iconForPresetKey", () => {
it("returns the mapped Tabler icon for a known key", () => {
expect(iconForPresetKey("eye")).toBe(IconEye);
});
it("falls back to sparkle for unknown or missing keys", () => {
expect(iconForPresetKey(undefined)).toBe(IconSparkles);
expect(iconForPresetKey("nonsense")).toBe(IconSparkles);
});
});
describe("resolvePRPresets / resolveIssuePresets", () => {
it("uses defaults when stored is null", () => {
const pr = resolvePRPresets(null);
expect(pr.length).toBe(DEFAULT_PR_PRESETS.length);
const issue = resolveIssuePresets(null);
expect(issue.length).toBe(DEFAULT_ISSUE_PRESETS.length);
});
it("uses defaults when the stored list is empty (treats as unset)", () => {
const pr = resolvePRPresets({ workspace_id: "w", pr: [], issue: [] });
expect(pr.length).toBe(DEFAULT_PR_PRESETS.length);
});
it("uses stored presets when non-empty", () => {
const stored = {
workspace_id: "w",
pr: [
{
id: "custom",
label: "Custom",
hint: "h",
icon: "eye",
prompt_template: "Do it on {url}",
},
],
issue: [],
};
const pr = resolvePRPresets(stored);
expect(pr).toHaveLength(1);
expect(pr[0].label).toBe("Custom");
expect(pr[0].prompt({ url: "https://x", title: "t" })).toBe("Do it on https://x");
});
});
describe("toTaskPreset", () => {
it("wires the prompt function to interpolate the stored template", () => {
const stored = {
id: "id",
label: "L",
hint: "H",
icon: "message",
prompt_template: "On {url} for {title}",
};
const preset = toTaskPreset(stored);
expect(preset.prompt({ url: "U", title: "T" })).toBe("On U for T");
});
});
@@ -0,0 +1,126 @@
import {
IconEye,
IconMessageDots,
IconTool,
IconCode,
IconSearch,
IconBug,
IconSparkles,
IconChecks,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import type {
GitHubActionPreset,
GitHubActionPresetIcon,
GitHubActionPresets,
} from "@/lib/types/github";
import type { TaskPreset } from "./quick-task-launcher";
export const PRESET_ICON_CHOICES: { key: GitHubActionPresetIcon; icon: Icon; label: string }[] = [
{ key: "eye", icon: IconEye, label: "Eye" },
{ key: "message", icon: IconMessageDots, label: "Message" },
{ key: "tool", icon: IconTool, label: "Tool" },
{ key: "code", icon: IconCode, label: "Code" },
{ key: "search", icon: IconSearch, label: "Search" },
{ key: "bug", icon: IconBug, label: "Bug" },
{ key: "sparkle", icon: IconSparkles, label: "Sparkle" },
{ key: "check", icon: IconChecks, label: "Check" },
];
const ICON_BY_KEY: Record<string, Icon> = Object.fromEntries(
PRESET_ICON_CHOICES.map((choice) => [choice.key, choice.icon]),
);
export function iconForPresetKey(key: string | undefined): Icon {
if (!key) return IconSparkles;
return ICON_BY_KEY[key] ?? IconSparkles;
}
// Interpolate `{url}` and `{title}` placeholders in a prompt template.
// Unknown placeholders are left untouched so the user sees what's broken.
export function interpolatePromptTemplate(
template: string,
opts: { url: string; title: string },
): string {
return template.replace(/\{(url|title)\}/g, (_match, key) => {
if (key === "url") return opts.url;
if (key === "title") return opts.title;
return `{${key}}`;
});
}
export function toTaskPreset(stored: GitHubActionPreset): TaskPreset {
return {
id: stored.id,
label: stored.label,
hint: stored.hint,
icon: iconForPresetKey(stored.icon),
prompt: (opts) => interpolatePromptTemplate(stored.prompt_template, opts),
};
}
export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
{
id: "review",
label: "Review",
hint: "Read the diff, flag issues",
icon: "eye",
prompt_template:
"Review the pull request at {url}. Provide feedback on code quality, correctness, and suggest improvements.",
},
{
id: "address_feedback",
label: "Address feedback",
hint: "Apply review comments",
icon: "message",
prompt_template:
"Address the review feedback on the pull request at {url}. Make the requested changes and push them.",
},
{
id: "fix_ci",
label: "Fix CI",
hint: "Diagnose failing checks",
icon: "tool",
prompt_template:
"Investigate and fix the CI failures on the pull request at {url}. Run the failing checks locally, diagnose, and push fixes.",
},
];
export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [
{
id: "implement",
label: "Implement",
hint: "Build and open a PR",
icon: "code",
prompt_template:
'Implement the changes described in the GitHub issue at {url} (title: "{title}"). Open a pull request when complete.',
},
{
id: "investigate",
label: "Investigate",
hint: "Find the root cause",
icon: "search",
prompt_template:
'Investigate the GitHub issue at {url} (title: "{title}"). Identify root cause and summarize findings.',
},
{
id: "reproduce",
label: "Reproduce",
hint: "Document repro steps",
icon: "bug",
prompt_template:
'Reproduce the bug described in the GitHub issue at {url} (title: "{title}"). Document the reproduction steps.',
},
];
// Resolve stored presets into runtime TaskPreset[]. Falls back to defaults while
// presets load so the /github page doesn't flicker empty dropdowns on first mount.
export function resolvePRPresets(stored: GitHubActionPresets | null): TaskPreset[] {
const source = stored?.pr?.length ? stored.pr : DEFAULT_PR_PRESETS;
return source.map(toTaskPreset);
}
export function resolveIssuePresets(stored: GitHubActionPresets | null): TaskPreset[] {
const source = stored?.issue?.length ? stored.issue : DEFAULT_ISSUE_PRESETS;
return source.map(toTaskPreset);
}
@@ -14,20 +14,23 @@ import { Spinner } from "@kandev/ui/spinner";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
import type { GitHubIssue } from "@/lib/types/github";
import { ISSUE_TASK_PRESETS, type LaunchPayload, type TaskPreset } from "./quick-task-launcher";
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
type IssueListProps = {
items: GitHubIssue[];
loading: boolean;
error: string | null;
presets: TaskPreset[];
onStartTask: (payload: LaunchPayload) => void;
};
function StartTaskMenu({
issue,
presets,
onStartTask,
}: {
issue: GitHubIssue;
presets: TaskPreset[];
onStartTask: IssueListProps["onStartTask"];
}) {
const launch = (preset: TaskPreset) => onStartTask({ kind: "issue", issue, preset });
@@ -41,7 +44,7 @@ function StartTaskMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{ISSUE_TASK_PRESETS.map((p) => {
{presets.map((p) => {
const ItemIcon = p.icon;
return (
<DropdownMenuItem
@@ -80,9 +83,11 @@ function IssueLabels({ labels }: { labels: string[] }) {
function IssueRow({
issue,
presets,
onStartTask,
}: {
issue: GitHubIssue;
presets: TaskPreset[];
onStartTask: IssueListProps["onStartTask"];
}) {
const StateIcon = issue.state === "open" ? IconCircle : IconCircleCheck;
@@ -114,13 +119,13 @@ function IssueRow({
</div>
</div>
<div className="shrink-0">
<StartTaskMenu issue={issue} onStartTask={onStartTask} />
<StartTaskMenu issue={issue} presets={presets} onStartTask={onStartTask} />
</div>
</div>
);
}
function IssueListBody({ loading, error, items, onStartTask }: IssueListProps) {
function IssueListBody({ loading, error, items, presets, onStartTask }: IssueListProps) {
if (loading) {
return (
<div className="flex justify-center py-10">
@@ -144,6 +149,7 @@ function IssueListBody({ loading, error, items, onStartTask }: IssueListProps) {
<IssueRow
key={`${issue.repo_owner}/${issue.repo_name}#${issue.number}`}
issue={issue}
presets={presets}
onStartTask={onStartTask}
/>
))}
@@ -19,7 +19,7 @@ import {
import { Spinner } from "@kandev/ui/spinner";
import { cn, formatRelativeTime } from "@/lib/utils";
import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github";
import { PR_TASK_PRESETS, type LaunchPayload, type TaskPreset } from "./quick-task-launcher";
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
import { PRStatusBadges } from "./pr-status-badges";
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
@@ -27,6 +27,7 @@ type PRListProps = {
items: GitHubPR[];
loading: boolean;
error: string | null;
presets: TaskPreset[];
onStartTask: (payload: LaunchPayload) => void;
};
@@ -41,9 +42,11 @@ function prStateIcon(pr: GitHubPR): { Icon: Icon; className: string } {
function StartTaskMenu({
pr,
presets,
onStartTask,
}: {
pr: GitHubPR;
presets: TaskPreset[];
onStartTask: PRListProps["onStartTask"];
}) {
const launch = (preset: TaskPreset) => onStartTask({ kind: "pr", pr, preset });
@@ -57,7 +60,7 @@ function StartTaskMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{PR_TASK_PRESETS.map((p) => {
{presets.map((p) => {
const ItemIcon = p.icon;
return (
<DropdownMenuItem
@@ -81,10 +84,12 @@ function StartTaskMenu({
function PRRow({
pr,
status,
presets,
onStartTask,
}: {
pr: GitHubPR;
status: GitHubPRStatus | null | undefined;
presets: TaskPreset[];
onStartTask: PRListProps["onStartTask"];
}) {
const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr);
@@ -112,13 +117,13 @@ function PRRow({
</div>
</div>
<div className="shrink-0">
<StartTaskMenu pr={pr} onStartTask={onStartTask} />
<StartTaskMenu pr={pr} presets={presets} onStartTask={onStartTask} />
</div>
</div>
);
}
function PRListBody({ loading, error, items, onStartTask }: PRListProps) {
function PRListBody({ loading, error, items, presets, onStartTask }: PRListProps) {
const statuses = usePRStatuses(items);
if (loading) {
return (
@@ -141,7 +146,15 @@ function PRListBody({ loading, error, items, onStartTask }: PRListProps) {
<div className="divide-y">
{items.map((pr) => {
const key = prStatusKey(pr.repo_owner, pr.repo_name, pr.number);
return <PRRow key={key} pr={pr} status={statuses.get(key)} onStartTask={onStartTask} />;
return (
<PRRow
key={key}
pr={pr}
status={statuses.get(key)}
presets={presets}
onStartTask={onStartTask}
/>
);
})}
</div>
);
@@ -2,14 +2,6 @@
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import {
IconEye,
IconMessageDots,
IconTool,
IconCode,
IconSearch,
IconBug,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http";
@@ -27,65 +19,12 @@ export type LaunchPayload =
| { kind: "pr"; pr: GitHubPR; preset: TaskPreset }
| { kind: "issue"; issue: GitHubIssue; preset: TaskPreset };
export const PR_TASK_PRESETS: TaskPreset[] = [
{
id: "review",
label: "Review",
hint: "Read the diff, flag issues",
icon: IconEye,
prompt: ({ url }) =>
`Review the pull request at ${url}. Provide feedback on code quality, correctness, and suggest improvements.`,
},
{
id: "address_feedback",
label: "Address feedback",
hint: "Apply review comments",
icon: IconMessageDots,
prompt: ({ url }) =>
`Address the review feedback on the pull request at ${url}. Make the requested changes and push them.`,
},
{
id: "fix_ci",
label: "Fix CI",
hint: "Diagnose failing checks",
icon: IconTool,
prompt: ({ url }) =>
`Investigate and fix the CI failures on the pull request at ${url}. Run the failing checks locally, diagnose, and push fixes.`,
},
];
export const ISSUE_TASK_PRESETS: TaskPreset[] = [
{
id: "implement",
label: "Implement",
hint: "Build and open a PR",
icon: IconCode,
prompt: ({ url, title }) =>
`Implement the changes described in the GitHub issue at ${url} (title: "${title}"). Open a pull request when complete.`,
},
{
id: "investigate",
label: "Investigate",
hint: "Find the root cause",
icon: IconSearch,
prompt: ({ url, title }) =>
`Investigate the GitHub issue at ${url} (title: "${title}"). Identify root cause and summarize findings.`,
},
{
id: "reproduce",
label: "Reproduce",
hint: "Document repro steps",
icon: IconBug,
prompt: ({ url, title }) =>
`Reproduce the bug described in the GitHub issue at ${url} (title: "${title}"). Document the reproduction steps.`,
},
];
type DialogState = {
title: string;
description: string;
repositoryId?: string;
branch?: string;
checkoutBranch?: string;
githubUrl?: string;
};
@@ -121,14 +60,22 @@ function buildDialogState(payload: LaunchPayload, repositories: Repository[]): D
const repo = matchRepo(repositories, data.owner, data.name);
const description = payload.preset.prompt({ url: data.url, title: data.title });
const title = `${payload.preset.label}: ${data.title}`;
const checkoutBranch = payload.kind === "pr" ? data.branch : undefined;
if (repo) {
return { title, description, repositoryId: repo.id, branch: data.branch };
return {
title,
description,
repositoryId: repo.id,
branch: data.branch,
checkoutBranch,
};
}
return {
title,
description,
githubUrl: `github.com/${data.owner}/${data.name}`,
branch: data.branch,
checkoutBranch,
};
}
@@ -194,6 +141,7 @@ export function QuickTaskLauncher({
description: dialog.description,
repositoryId: dialog.repositoryId,
branch: dialog.branch,
checkoutBranch: dialog.checkoutBranch,
githubUrl: dialog.githubUrl,
}}
onSuccess={handleSuccess}
@@ -172,7 +172,13 @@ export function buildRepositoriesPayload(opts: {
];
}
if (opts.repositoryId) {
return [{ repository_id: opts.repositoryId, base_branch: opts.branch || undefined }];
return [
{
repository_id: opts.repositoryId,
base_branch: opts.branch || undefined,
checkout_branch: opts.githubPrHeadBranch || undefined,
},
];
}
if (opts.selectedLocalRepo) {
return [
@@ -172,7 +172,7 @@ function resetDiscoveryState(resetters: FormResetters, iv?: TaskCreateDialogInit
resetters.setGitHubUrl(ghUrl);
resetters.setGitHubBranches([]);
resetters.setGitHubUrlError(null);
resetters.setGitHubPrHeadBranch(null);
resetters.setGitHubPrHeadBranch(iv?.checkoutBranch ?? null);
}
/** Hook to manage draft persistence for task creation dialog */
@@ -29,6 +29,9 @@ export type TaskCreateDialogInitialValues = {
description?: string;
repositoryId?: string;
branch?: string;
/** Existing remote branch to check out directly in the worktree (e.g. a PR's head branch),
* instead of creating a new branch off `branch`. */
checkoutBranch?: string;
state?: Task["state"];
/** When set, opens the dialog in GitHub URL mode pre-filled with this value
* (e.g. "github.com/owner/repo"). Used when no matching workspace repo exists. */
@@ -0,0 +1,65 @@
"use client";
import { useEffect, useCallback, useRef } from "react";
import {
fetchGitHubActionPresets,
updateGitHubActionPresets,
resetGitHubActionPresets,
} from "@/lib/api/domains/github-api";
import { useAppStore } from "@/components/state-provider";
import type { UpdateGitHubActionPresetsRequest } from "@/lib/types/github";
export function useGitHubActionPresets(workspaceId: string | null) {
const presets = useAppStore((state) =>
workspaceId ? (state.actionPresets.byWorkspaceId[workspaceId] ?? null) : null,
);
const loading = useAppStore((state) =>
workspaceId ? Boolean(state.actionPresets.loading[workspaceId]) : false,
);
const setPresets = useAppStore((state) => state.setActionPresets);
const setLoading = useAppStore((state) => state.setActionPresetsLoading);
// Tracks which workspaces we've already attempted a fetch for so a
// network failure does not cause the effect to re-fire indefinitely.
const attemptedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!workspaceId) return;
if (presets || attemptedRef.current.has(workspaceId)) return;
attemptedRef.current.add(workspaceId);
setLoading(workspaceId, true);
fetchGitHubActionPresets(workspaceId, { cache: "no-store" })
.then((response) => {
if (response) setPresets(workspaceId, response);
})
.catch(() => {
// Leave presets unset on failure; consumers fall back to defaults.
})
.finally(() => {
setLoading(workspaceId, false);
});
}, [workspaceId, presets, setPresets, setLoading]);
const save = useCallback(
async (payload: Omit<UpdateGitHubActionPresetsRequest, "workspace_id">) => {
if (!workspaceId) return null;
const response = await updateGitHubActionPresets({ workspace_id: workspaceId, ...payload });
if (response) setPresets(workspaceId, response);
return response;
},
[workspaceId, setPresets],
);
const reset = useCallback(async () => {
if (!workspaceId) return null;
const response = await resetGitHubActionPresets(workspaceId);
if (response) setPresets(workspaceId, response);
return response;
}, [workspaceId, setPresets]);
return {
presets,
loading,
save,
reset,
};
}
+29
View File
@@ -22,6 +22,8 @@ import type {
SearchPRsResponse,
SearchIssuesResponse,
GitHubPRStatus,
GitHubActionPresets,
UpdateGitHubActionPresetsRequest,
} from "@/lib/types/github";
// Status
@@ -306,3 +308,30 @@ export async function searchUserIssues(params: SearchParams, options?: ApiReques
options,
);
}
// Action presets (quick-launch prompts on the /github page).
export async function fetchGitHubActionPresets(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<GitHubActionPresets>(
`/api/v1/github/action-presets?${query.toString()}`,
options,
);
}
export async function updateGitHubActionPresets(
payload: UpdateGitHubActionPresetsRequest,
options?: ApiRequestOptions,
) {
return fetchJson<GitHubActionPresets>("/api/v1/github/action-presets", {
...options,
init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(payload) },
});
}
export async function resetGitHubActionPresets(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<GitHubActionPresets>(`/api/v1/github/action-presets/reset?${query.toString()}`, {
...options,
init: { ...(options?.init ?? {}), method: "POST" },
});
}
+2
View File
@@ -61,6 +61,7 @@ export const defaultState = {
prWatches: defaultGitHubState.prWatches,
reviewWatches: defaultGitHubState.reviewWatches,
issueWatches: defaultGitHubState.issueWatches,
actionPresets: defaultGitHubState.actionPresets,
previewPanel: defaultUIState.previewPanel,
rightPanel: defaultUIState.rightPanel,
diffs: defaultUIState.diffs,
@@ -142,6 +143,7 @@ export function mergeInitialState(initialState?: Partial<DefaultState>): Default
prWatches: { ...defaultState.prWatches, ...initialState.prWatches },
reviewWatches: { ...defaultState.reviewWatches, ...initialState.reviewWatches },
issueWatches: { ...defaultState.issueWatches, ...initialState.issueWatches },
actionPresets: { ...defaultState.actionPresets, ...initialState.actionPresets },
previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel },
rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel },
diffs: { ...defaultState.diffs, ...initialState.diffs },
@@ -7,6 +7,7 @@ export const defaultGitHubState: GitHubSliceState = {
prWatches: { items: [], loaded: false, loading: false },
reviewWatches: { items: [], loaded: false, loading: false },
issueWatches: { items: [], loaded: false, loading: false },
actionPresets: { byWorkspaceId: {}, loading: {} },
};
type ImmerSet = Parameters<
@@ -144,6 +145,21 @@ function createWatchActions(
};
}
function createActionPresetActions(
set: ImmerSet,
): Pick<GitHubSlice, "setActionPresets" | "setActionPresetsLoading"> {
return {
setActionPresets: (workspaceId, presets) =>
set((draft) => {
draft.actionPresets.byWorkspaceId[workspaceId] = presets;
}),
setActionPresetsLoading: (workspaceId, loading) =>
set((draft) => {
draft.actionPresets.loading[workspaceId] = loading;
}),
};
}
export const createGitHubSlice: StateCreator<
GitHubSlice,
[["zustand/immer", never]],
@@ -154,4 +170,5 @@ export const createGitHubSlice: StateCreator<
...createGitHubStatusActions(set),
...createTaskPRActions(set),
...createWatchActions(set),
...createActionPresetActions(set),
});
+16 -1
View File
@@ -1,4 +1,11 @@
import type { GitHubStatus, TaskPR, PRWatch, ReviewWatch, IssueWatch } from "@/lib/types/github";
import type {
GitHubStatus,
TaskPR,
PRWatch,
ReviewWatch,
IssueWatch,
GitHubActionPresets,
} from "@/lib/types/github";
export type GitHubStatusState = {
status: GitHubStatus | null;
@@ -30,12 +37,18 @@ export type IssueWatchesState = {
loading: boolean;
};
export type ActionPresetsState = {
byWorkspaceId: Record<string, GitHubActionPresets>;
loading: Record<string, boolean>;
};
export type GitHubSliceState = {
githubStatus: GitHubStatusState;
taskPRs: TaskPRsState;
prWatches: PRWatchesState;
reviewWatches: ReviewWatchesState;
issueWatches: IssueWatchesState;
actionPresets: ActionPresetsState;
};
export type GitHubSliceActions = {
@@ -58,6 +71,8 @@ export type GitHubSliceActions = {
addIssueWatch: (watch: IssueWatch) => void;
updateIssueWatch: (watch: IssueWatch) => void;
removeIssueWatch: (id: string) => void;
setActionPresets: (workspaceId: string, presets: GitHubActionPresets) => void;
setActionPresetsLoading: (workspaceId: string, loading: boolean) => void;
};
export type GitHubSlice = GitHubSliceState & GitHubSliceActions;
+5
View File
@@ -15,6 +15,7 @@ import type {
PRWatch,
ReviewWatch as GitHubReviewWatch,
IssueWatch as GitHubIssueWatch,
GitHubActionPresets,
} from "@/lib/types/github";
import type { SystemHealthResponse } from "@/lib/types/health";
import type { UISliceActions as UIA } from "./slices/ui/types";
@@ -200,6 +201,7 @@ export type AppState = {
prWatches: (typeof defaultGitHubState)["prWatches"];
reviewWatches: (typeof defaultGitHubState)["reviewWatches"];
issueWatches: (typeof defaultGitHubState)["issueWatches"];
actionPresets: (typeof defaultGitHubState)["actionPresets"];
// UI slice
previewPanel: (typeof defaultUIState)["previewPanel"];
@@ -238,6 +240,8 @@ export type AppState = {
addIssueWatch: (watch: GitHubIssueWatch) => void;
updateIssueWatch: (watch: GitHubIssueWatch) => void;
removeIssueWatch: (id: string) => void;
setActionPresets: (workspaceId: string, presets: GitHubActionPresets) => void;
setActionPresetsLoading: (workspaceId: string, loading: boolean) => void;
// Actions from all slices
hydrate: (state: Partial<AppState>, options?: HydrationOptions) => void;
@@ -508,6 +512,7 @@ export function createAppStore(initialState?: Partial<AppState>) {
prWatches: merged.prWatches,
reviewWatches: merged.reviewWatches,
issueWatches: merged.issueWatches,
actionPresets: merged.actionPresets,
previewPanel: merged.previewPanel,
rightPanel: merged.rightPanel,
diffs: merged.diffs,
+35
View File
@@ -337,3 +337,38 @@ export type SearchIssuesResponse = {
page: number;
per_page: number;
};
// Action presets — configurable quick-launch prompts on the /github page.
export type GitHubActionPresetKind = "pr" | "issue";
export type GitHubActionPresetIcon =
| "eye"
| "message"
| "tool"
| "code"
| "search"
| "bug"
| "sparkle"
| "check";
export type GitHubActionPreset = {
id: string;
label: string;
hint: string;
// `string & {}` preserves autocomplete for the known icon keys while still
// accepting custom strings for forward compatibility.
icon: GitHubActionPresetIcon | (string & {});
prompt_template: string;
};
export type GitHubActionPresets = {
workspace_id: string;
pr: GitHubActionPreset[];
issue: GitHubActionPreset[];
};
export type UpdateGitHubActionPresetsRequest = {
workspace_id: string;
pr?: GitHubActionPreset[];
issue?: GitHubActionPreset[];
};