From ccc62d93242205aa08ca9e2a7071100ac7206e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 24 Apr 2026 14:10:59 +0100 Subject: [PATCH] feat(github): configurable quick-action presets and PR branch checkout (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- .../internal/github/action_presets_service.go | 96 ++++++ .../internal/github/action_presets_test.go | 155 +++++++++ apps/backend/internal/github/controller.go | 52 +++ apps/backend/internal/github/handlers.go | 36 +++ apps/backend/internal/github/models.go | 90 ++++++ apps/backend/internal/github/store.go | 63 ++++ apps/backend/pkg/websocket/actions.go | 5 + apps/web/app/github/github-page-client.tsx | 29 +- .../github/action-presets-section.tsx | 298 ++++++++++++++++++ .../web/components/github/github-settings.tsx | 2 + .../github/my-github/action-presets.test.ts | 94 ++++++ .../github/my-github/action-presets.ts | 126 ++++++++ .../github/my-github/issue-list.tsx | 14 +- .../components/github/my-github/pr-list.tsx | 23 +- .../github/my-github/quick-task-launcher.tsx | 74 +---- .../components/task-create-dialog-helpers.ts | 8 +- .../components/task-create-dialog-state.ts | 2 +- .../components/task-create-dialog-types.ts | 3 + .../github/use-github-action-presets.ts | 65 ++++ apps/web/lib/api/domains/github-api.ts | 29 ++ apps/web/lib/state/default-state.ts | 2 + .../lib/state/slices/github/github-slice.ts | 17 + apps/web/lib/state/slices/github/types.ts | 17 +- apps/web/lib/state/store.ts | 5 + apps/web/lib/types/github.ts | 35 ++ 25 files changed, 1264 insertions(+), 76 deletions(-) create mode 100644 apps/backend/internal/github/action_presets_service.go create mode 100644 apps/backend/internal/github/action_presets_test.go create mode 100644 apps/web/components/github/action-presets-section.tsx create mode 100644 apps/web/components/github/my-github/action-presets.test.ts create mode 100644 apps/web/components/github/my-github/action-presets.ts create mode 100644 apps/web/hooks/domains/github/use-github-action-presets.ts diff --git a/apps/backend/internal/github/action_presets_service.go b/apps/backend/internal/github/action_presets_service.go new file mode 100644 index 0000000000..47579efe2d --- /dev/null +++ b/apps/backend/internal/github/action_presets_service.go @@ -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 +} diff --git a/apps/backend/internal/github/action_presets_test.go b/apps/backend/internal/github/action_presets_test.go new file mode 100644 index 0000000000..f3b2c89bc5 --- /dev/null +++ b/apps/backend/internal/github/action_presets_test.go @@ -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") + } +} diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index 48137235b9..26b1b48881 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -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) +} diff --git a/apps/backend/internal/github/handlers.go b/apps/backend/internal/github/handlers.go index 957cbace7e..d0b9a58e68 100644 --- a/apps/backend/internal/github/handlers.go +++ b/apps/backend/internal/github/handlers.go @@ -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) + }) +} diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index d17e6ccf11..632867bc5f 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -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.`, + }, + } +} diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index 83afb541d1..c0ae8fea6f 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -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 +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 71925f714d..4007d2b45a 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -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 diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index bae37315e8..0ab3042c39 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -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; 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} /> @@ -309,6 +327,9 @@ export function GitHubPageClient({ const { status, loaded } = useGitHubStatus(); const [launchPayload, setLaunchPayload] = useState(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({ )} {loaded && authed && ( - + )} void }) { + return ( + + ); +} + +function PresetRow({ + preset, + expanded, + onToggle, + onPatch, + onRemove, +}: { + preset: GitHubActionPreset; + expanded: boolean; + onToggle: () => void; + onPatch: (patch: Partial) => void; + onRemove: () => void; +}) { + return ( +
+
+ onPatch({ icon: v })} /> + onPatch({ label: e.target.value })} + /> + onPatch({ hint: e.target.value })} + /> + + +
+ {expanded && ( +
+