Files
João Salavisa ccc62d9324 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>
2026-04-24 14:10:59 +01:00

156 lines
4.8 KiB
Go

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")
}
}