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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user