640 lines
20 KiB
Go
640 lines
20 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"superwork-tui/internal/cc"
|
|
"superwork-tui/internal/config"
|
|
"superwork-tui/internal/gitea"
|
|
"superwork-tui/internal/issue"
|
|
"superwork-tui/internal/webhook"
|
|
)
|
|
|
|
// ── waitForWebhookCmd ────────────────────────────────────────────────────────
|
|
|
|
func TestWaitForWebhookCmd_DeliversEvent(t *testing.T) {
|
|
ch := make(chan webhook.Event, 1)
|
|
ch <- webhook.PushEvent{Branch: "main"}
|
|
ctx := context.Background()
|
|
cmd := waitForWebhookCmd(ctx, ch)
|
|
msg := cmd()
|
|
got, ok := msg.(webhookEventMsg)
|
|
if !ok {
|
|
t.Fatalf("want webhookEventMsg, got %T", msg)
|
|
}
|
|
if _, ok := got.ev.(webhook.PushEvent); !ok {
|
|
t.Fatalf("want PushEvent, got %T", got.ev)
|
|
}
|
|
}
|
|
|
|
// Fix 1: goroutine leak — cancelling ctx must cause waitForWebhookCmd to
|
|
// return nil (not block forever), and the dispatch loop must NOT re-issue.
|
|
func TestWaitForWebhookCmd_CancelReturnsNil(t *testing.T) {
|
|
ch := make(chan webhook.Event) // unbuffered — no event will arrive
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // already cancelled
|
|
cmd := waitForWebhookCmd(ctx, ch)
|
|
msg := cmd()
|
|
if msg != nil {
|
|
t.Fatalf("want nil msg on cancel, got %T", msg)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_WebhookEventMsg_NilEvDoesNotReissue(t *testing.T) {
|
|
m := Model{
|
|
webhookCtx: func() context.Context {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
return ctx
|
|
}(),
|
|
webhookCh: make(chan webhook.Event),
|
|
}
|
|
// A nil ev (produced by the cancel path) must return no cmd (loop terminated).
|
|
newModel, cmd := m.Update(webhookEventMsg{ev: nil})
|
|
_ = newModel
|
|
if cmd != nil {
|
|
t.Error("expected nil cmd when ev is nil (cancel path), got non-nil")
|
|
}
|
|
}
|
|
|
|
// ── PushEvent dispatch ────────────────────────────────────────────────────────
|
|
|
|
func TestDispatch_PushEvent_DevBranch_TriggersBranchSync(t *testing.T) {
|
|
origLoad := configLoadFn
|
|
defer func() { configLoadFn = origLoad }()
|
|
configLoadFn = func() (*config.Settings, error) {
|
|
return &config.Settings{DevBranch: "main", WebhookPort: 17421}, nil
|
|
}
|
|
|
|
syncCalled := false
|
|
origSync := branchSyncCmdFn
|
|
defer func() { branchSyncCmdFn = origSync }()
|
|
branchSyncCmdFn = func() tea.Cmd {
|
|
syncCalled = true
|
|
return nil
|
|
}
|
|
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
m, _ = handlePushEvent(m, webhook.PushEvent{Branch: "main"}, cmds)
|
|
_ = m
|
|
|
|
if !syncCalled {
|
|
t.Error("expected branchSyncCmdFn to be called for dev branch push")
|
|
}
|
|
}
|
|
|
|
func TestDispatch_PushEvent_UnrelatedBranch_NoSync(t *testing.T) {
|
|
origLoad := configLoadFn
|
|
defer func() { configLoadFn = origLoad }()
|
|
configLoadFn = func() (*config.Settings, error) {
|
|
return &config.Settings{DevBranch: "main", WebhookPort: 17421}, nil
|
|
}
|
|
|
|
syncCalled := false
|
|
origSync := branchSyncCmdFn
|
|
defer func() { branchSyncCmdFn = origSync }()
|
|
branchSyncCmdFn = func() tea.Cmd {
|
|
syncCalled = true
|
|
return nil
|
|
}
|
|
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
m, _ = handlePushEvent(m, webhook.PushEvent{Branch: "feature/unrelated"}, cmds)
|
|
_ = m
|
|
|
|
if syncCalled {
|
|
t.Error("branchSyncCmdFn should not be called for unrelated branch")
|
|
}
|
|
}
|
|
|
|
// ── IssueCommentEvent dispatch ────────────────────────────────────────────────
|
|
|
|
func TestDispatch_IssueCommentEvent_NonMarker_NoOp(t *testing.T) {
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{
|
|
{Number: 5, PR: "10"},
|
|
},
|
|
}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handleIssueCommentEvent(m, webhook.IssueCommentEvent{
|
|
IssueNumber: 5,
|
|
CommentBody: "just a regular comment",
|
|
}, cmds)
|
|
_ = m
|
|
if len(resultCmds) != 0 {
|
|
t.Errorf("expected no cmds for non-marker comment, got %d", len(resultCmds))
|
|
}
|
|
}
|
|
|
|
func TestDispatch_IssueCommentEvent_MarkerOnlyBody_NoOp(t *testing.T) {
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{{Number: 5}},
|
|
sessionMgr: NewSessionManager(func(args ...string) ([]byte, error) {
|
|
return nil, nil
|
|
}),
|
|
}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handleIssueCommentEvent(m, webhook.IssueCommentEvent{
|
|
IssueNumber: 5,
|
|
CommentBody: "<!-- spx:review=1 -->",
|
|
}, cmds)
|
|
_ = m
|
|
if len(resultCmds) != 0 {
|
|
t.Errorf("expected no cmds for marker-only body, got %d", len(resultCmds))
|
|
}
|
|
}
|
|
|
|
func TestDispatch_IssueCommentEvent_Marker_InjectsIntoImplTab(t *testing.T) {
|
|
// windowExists for the impl window must return true, so InjectFeedback
|
|
// injects via send-keys. resolveSessionName() default session is "superwork";
|
|
// windowName(5, PhaseImpl) is "5-impl".
|
|
rec := &recorder{
|
|
responses: map[string][]byte{
|
|
"has-session": {},
|
|
"list-windows": []byte("5-impl\n"),
|
|
},
|
|
}
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{{Number: 5, PR: "10"}},
|
|
sessionMgr: NewSessionManager(rec.run),
|
|
}
|
|
var cmds []tea.Cmd
|
|
m, _ = handleIssueCommentEvent(m, webhook.IssueCommentEvent{
|
|
IssueNumber: 5,
|
|
PRNumber: 10,
|
|
CommentBody: "<!-- spx:review=1 -->\nplease fix the null check",
|
|
}, cmds)
|
|
|
|
var sentText string
|
|
var hasSendKeys bool
|
|
for _, call := range rec.calls {
|
|
if len(call) > 0 && call[0] == "send-keys" {
|
|
hasSendKeys = true
|
|
for _, a := range call {
|
|
if strings.Contains(a, "please fix the null check") {
|
|
sentText = a
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !hasSendKeys {
|
|
t.Fatal("expected send-keys call (InjectFeedback should inject into impl tab)")
|
|
}
|
|
if sentText == "" {
|
|
t.Error("expected the stripped marker text to be injected")
|
|
}
|
|
if strings.Contains(sentText, "spx:review") {
|
|
t.Error("review marker should have been stripped before injection")
|
|
}
|
|
if !strings.Contains(m.statusMsg, "已注入") {
|
|
t.Errorf("expected success statusMsg, got %q", m.statusMsg)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_IssueCommentEvent_IssueNotFound(t *testing.T) {
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{},
|
|
sessionMgr: NewSessionManager(func(args ...string) ([]byte, error) {
|
|
return nil, nil
|
|
}),
|
|
}
|
|
var cmds []tea.Cmd
|
|
m, _ = handleIssueCommentEvent(m, webhook.IssueCommentEvent{
|
|
IssueNumber: 99,
|
|
CommentBody: "<!-- spx:review=1 -->\nsome feedback",
|
|
}, cmds)
|
|
|
|
if m.statusMsg == "" {
|
|
t.Error("expected statusMsg to be set when issue not found")
|
|
}
|
|
}
|
|
|
|
// ── IssueEvent dispatch ───────────────────────────────────────────────────────
|
|
|
|
func TestDispatch_IssueEvent_Opened_QueuesLoad(t *testing.T) {
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handleIssueEvent(m, webhook.IssueEvent{
|
|
Action: "opened",
|
|
IssueNumber: 7,
|
|
Body: "some body",
|
|
}, cmds)
|
|
if m.statusMsg == "" {
|
|
t.Error("expected statusMsg after issue opened event")
|
|
}
|
|
if len(resultCmds) == 0 {
|
|
t.Error("expected at least one cmd (loadCmd) after issue opened event")
|
|
}
|
|
}
|
|
|
|
func TestDispatch_IssueEvent_Edited_QueuesSync(t *testing.T) {
|
|
// "edited" now queues a syncIssueSpecPlanCmd (even with no markers — the cmd
|
|
// itself is a no-op when no markers are found). Verify a cmd is queued and
|
|
// the statusMsg is updated.
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handleIssueEvent(m, webhook.IssueEvent{
|
|
Action: "edited",
|
|
IssueNumber: 7,
|
|
Body: "",
|
|
}, cmds)
|
|
if len(resultCmds) != 1 {
|
|
t.Errorf("edited action should queue syncIssueSpecPlanCmd, got %d cmds", len(resultCmds))
|
|
}
|
|
if m.statusMsg == "" {
|
|
t.Error("expected statusMsg to be set for edited action")
|
|
}
|
|
}
|
|
|
|
// ── autoReviewCapturedMsg handling ───────────────────────────────────────────
|
|
|
|
func TestUpdate_AutoReviewCapturedMsg_UpdatesIssue(t *testing.T) {
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{{Number: 3, Branch: "feat/3-thing"}},
|
|
}
|
|
|
|
msg := autoReviewCapturedMsg{issueNumber: 3, sessionID: "abc123"}
|
|
newModel, cmd := m.Update(msg)
|
|
nm := newModel.(Model)
|
|
|
|
found := false
|
|
for _, iss := range nm.issues {
|
|
if iss.Number == 3 && iss.ReviewSessionID == "abc123" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected ReviewSessionID to be set on issue #3")
|
|
}
|
|
if nm.statusMsg == "" {
|
|
t.Error("expected statusMsg to be set")
|
|
}
|
|
if cmd == nil {
|
|
t.Error("expected non-nil persist cmd")
|
|
}
|
|
}
|
|
|
|
func TestUpdate_AutoReviewCapturedMsg_EmptySession_NoOp(t *testing.T) {
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{{Number: 3}},
|
|
}
|
|
msg := autoReviewCapturedMsg{issueNumber: 3, sessionID: ""}
|
|
newModel, cmd := m.Update(msg)
|
|
nm := newModel.(Model)
|
|
for _, iss := range nm.issues {
|
|
if iss.Number == 3 && iss.ReviewSessionID != "" {
|
|
t.Error("expected ReviewSessionID to remain empty")
|
|
}
|
|
}
|
|
if cmd != nil {
|
|
t.Error("expected nil cmd when sessionID is empty")
|
|
}
|
|
}
|
|
|
|
// ── auto-review capture chain ────────────────────────────────────────────────
|
|
|
|
// triggerAutoReviewCmd must capture the reviewSessionId via the injected codex
|
|
// session watcher seam (which fires early), surfacing it as autoReviewCapturedMsg.
|
|
func TestTriggerAutoReviewCmd_CapturesSessionIDViaWatcherSeam(t *testing.T) {
|
|
origWatch := webhookWatchCodexSessionFn
|
|
defer func() { webhookWatchCodexSessionFn = origWatch }()
|
|
var gotSessionsDir string
|
|
webhookWatchCodexSessionFn = func(_ context.Context, opts cc.CodexSessionWatchOpts) (string, error) {
|
|
gotSessionsDir = opts.SessionsDir
|
|
return "rollout-session-xyz", nil
|
|
}
|
|
|
|
origLoad := configLoadFn
|
|
defer func() { configLoadFn = origLoad }()
|
|
configLoadFn = func() (*config.Settings, error) {
|
|
return &config.Settings{}, nil
|
|
}
|
|
|
|
cmd := triggerAutoReviewCmd(3, "7", t.TempDir())
|
|
msg := cmd()
|
|
captured, ok := msg.(autoReviewCapturedMsg)
|
|
if !ok {
|
|
t.Fatalf("want autoReviewCapturedMsg, got %T", msg)
|
|
}
|
|
if captured.issueNumber != 3 {
|
|
t.Errorf("issueNumber = %d, want 3", captured.issueNumber)
|
|
}
|
|
if captured.sessionID != "rollout-session-xyz" {
|
|
t.Errorf("sessionID = %q, want rollout-session-xyz", captured.sessionID)
|
|
}
|
|
if gotSessionsDir == "" {
|
|
t.Error("expected the watcher to be called with a codex sessions dir")
|
|
}
|
|
}
|
|
|
|
// After capture, Update wires the persist cmd; running it must invoke MergeStateJSON
|
|
// to write reviewSessionId. We assert the captured-msg → persist-cmd handoff.
|
|
func TestUpdate_AutoReviewCapturedMsg_QueuesPersist(t *testing.T) {
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{{Number: 3}},
|
|
}
|
|
_, cmd := m.Update(autoReviewCapturedMsg{issueNumber: 3, sessionID: "sid-1"})
|
|
if cmd == nil {
|
|
t.Fatal("expected a persist cmd after capturing a non-empty session id")
|
|
}
|
|
}
|
|
|
|
// ── PrEvent opened dispatch ──────────────────────────────────────────────────
|
|
|
|
// PR opened resolves the issue number from the body (Closes #N) and queues the
|
|
// state-merge, conditional auto-review, and refresh cmds.
|
|
func TestDispatch_PrEvent_Opened_ResolvesIssueAndQueuesCmds(t *testing.T) {
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{{Number: 8, Branch: "feat/8"}},
|
|
}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
|
|
Action: "opened",
|
|
PR: "12",
|
|
Branch: "feat/8",
|
|
Body: "Closes #8",
|
|
}, cmds)
|
|
|
|
// mergePRStateCmd, conditionalAutoReviewCmd, loadCmd.
|
|
if len(resultCmds) != 3 {
|
|
t.Fatalf("expected 3 cmds queued, got %d", len(resultCmds))
|
|
}
|
|
if !strings.Contains(m.statusMsg, "#8") {
|
|
t.Errorf("expected statusMsg to mention #8, got %q", m.statusMsg)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_PrEvent_Opened_UnresolvableIssue_Warns(t *testing.T) {
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
|
|
Action: "opened",
|
|
PR: "12",
|
|
Branch: "",
|
|
Body: "no closes keyword here",
|
|
}, cmds)
|
|
if len(resultCmds) != 0 {
|
|
t.Errorf("expected no cmds when issue is unresolvable, got %d", len(resultCmds))
|
|
}
|
|
if m.statusMsg == "" {
|
|
t.Error("expected a warning statusMsg when issue cannot be resolved")
|
|
}
|
|
}
|
|
|
|
// PR synchronize re-triggers review only when the matched issue already has a
|
|
// reviewSessionId.
|
|
func TestDispatch_PrEvent_Synchronize_WithReviewSession_Retriggers(t *testing.T) {
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{
|
|
{Number: 8, Branch: "feat/8", ReviewSessionID: "sid-old", WorktreePath: "wt/8"},
|
|
},
|
|
}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
|
|
Action: "synchronize",
|
|
PR: "12",
|
|
Branch: "feat/8",
|
|
}, cmds)
|
|
// triggerAutoReviewCmd + loadCmd.
|
|
if len(resultCmds) != 2 {
|
|
t.Fatalf("expected 2 cmds (re-review + load), got %d", len(resultCmds))
|
|
}
|
|
if !strings.Contains(m.statusMsg, "更新审查") {
|
|
t.Errorf("expected update-review statusMsg, got %q", m.statusMsg)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_PrEvent_Synchronize_NoReviewSession_OnlyRefreshes(t *testing.T) {
|
|
m := Model{
|
|
webhookCh: make(chan webhook.Event, 1),
|
|
issues: []issue.Issue{{Number: 8, Branch: "feat/8"}},
|
|
}
|
|
var cmds []tea.Cmd
|
|
_, resultCmds := handlePrEvent(m, webhook.PrEvent{
|
|
Action: "synchronize",
|
|
PR: "12",
|
|
Branch: "feat/8",
|
|
}, cmds)
|
|
// Only loadCmd.
|
|
if len(resultCmds) != 1 {
|
|
t.Fatalf("expected only the refresh cmd, got %d", len(resultCmds))
|
|
}
|
|
}
|
|
|
|
// ── PR closed/merged (Fix 2) ─────────────────────────────────────────────────
|
|
|
|
func TestDispatch_PrEvent_Closed_Merged_WritesPrMerged(t *testing.T) {
|
|
origMerge := mergeStateJSONFn
|
|
defer func() { mergeStateJSONFn = origMerge }()
|
|
|
|
var capturedExtra map[string]any
|
|
mergeStateJSONFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int, extra map[string]any) error {
|
|
capturedExtra = extra
|
|
return nil
|
|
}
|
|
|
|
origGet := getPullRequestFn
|
|
defer func() { getPullRequestFn = origGet }()
|
|
getPullRequestFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int) (*gitea.PullRequest, error) {
|
|
return &gitea.PullRequest{Merged: true, MergedAt: "2026-01-01T00:00:00Z"}, nil
|
|
}
|
|
|
|
origRepo := resolveGiteaRepoFn
|
|
defer func() { resolveGiteaRepoFn = origRepo }()
|
|
resolveGiteaRepoFn = func() (*giteaRepoCtx, error) {
|
|
return &giteaRepoCtx{client: &gitea.Client{}, owner: "o", repo: "r"}, nil
|
|
}
|
|
|
|
// Queue a closed event and check two cmds: prClosedCmd + loadCmd.
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
|
|
Action: "closed",
|
|
PR: "5",
|
|
Branch: "feat/8",
|
|
Body: "Closes #8",
|
|
IssueNumber: 8,
|
|
}, cmds)
|
|
if len(resultCmds) != 2 {
|
|
t.Fatalf("expected prClosedCmd + loadCmd (2), got %d", len(resultCmds))
|
|
}
|
|
if !strings.Contains(m.statusMsg, "#8") {
|
|
t.Errorf("expected statusMsg to mention #8, got %q", m.statusMsg)
|
|
}
|
|
|
|
// Run the prClosedCmd (first in list). It calls getPullRequestFn (merged=true)
|
|
// and should invoke mergeStateJSONFn with prMerged=true.
|
|
msg := resultCmds[0]()
|
|
_ = msg
|
|
if capturedExtra == nil {
|
|
t.Fatal("expected mergeStateJSONFn to be called when PR is merged")
|
|
}
|
|
if v, ok := capturedExtra["prMerged"]; !ok || v != true {
|
|
t.Errorf("expected prMerged=true in state JSON, got %v", capturedExtra)
|
|
}
|
|
if _, ok := capturedExtra["prMergedAt"]; !ok {
|
|
t.Error("expected prMergedAt to be set")
|
|
}
|
|
}
|
|
|
|
func TestDispatch_PrEvent_Closed_NotMerged_NoWrite(t *testing.T) {
|
|
origMerge := mergeStateJSONFn
|
|
defer func() { mergeStateJSONFn = origMerge }()
|
|
called := false
|
|
mergeStateJSONFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int, _ map[string]any) error {
|
|
called = true
|
|
return nil
|
|
}
|
|
|
|
origGet := getPullRequestFn
|
|
defer func() { getPullRequestFn = origGet }()
|
|
getPullRequestFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int) (*gitea.PullRequest, error) {
|
|
return &gitea.PullRequest{Merged: false}, nil
|
|
}
|
|
|
|
origRepo := resolveGiteaRepoFn
|
|
defer func() { resolveGiteaRepoFn = origRepo }()
|
|
resolveGiteaRepoFn = func() (*giteaRepoCtx, error) {
|
|
return &giteaRepoCtx{client: &gitea.Client{}, owner: "o", repo: "r"}, nil
|
|
}
|
|
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
_, resultCmds := handlePrEvent(m, webhook.PrEvent{
|
|
Action: "closed",
|
|
PR: "5",
|
|
Branch: "feat/8",
|
|
Body: "Closes #8",
|
|
IssueNumber: 8,
|
|
}, cmds)
|
|
// Run the prClosedCmd.
|
|
resultCmds[0]()
|
|
if called {
|
|
t.Error("mergeStateJSONFn must NOT be called when PR is not merged")
|
|
}
|
|
}
|
|
|
|
// ── PR deleted (Fix 2) ───────────────────────────────────────────────────────
|
|
|
|
func TestDispatch_PrEvent_Deleted_ClearsPrField(t *testing.T) {
|
|
origMerge := mergeStateJSONFn
|
|
defer func() { mergeStateJSONFn = origMerge }()
|
|
|
|
var capturedExtra map[string]any
|
|
mergeStateJSONFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int, extra map[string]any) error {
|
|
capturedExtra = extra
|
|
return nil
|
|
}
|
|
|
|
origRepo := resolveGiteaRepoFn
|
|
defer func() { resolveGiteaRepoFn = origRepo }()
|
|
resolveGiteaRepoFn = func() (*giteaRepoCtx, error) {
|
|
return &giteaRepoCtx{client: &gitea.Client{}, owner: "o", repo: "r"}, nil
|
|
}
|
|
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
|
|
Action: "deleted",
|
|
PR: "5",
|
|
Branch: "feat/8",
|
|
Body: "Closes #8",
|
|
IssueNumber: 8,
|
|
}, cmds)
|
|
if len(resultCmds) != 2 {
|
|
t.Fatalf("expected prDeletedCmd + loadCmd (2), got %d", len(resultCmds))
|
|
}
|
|
if !strings.Contains(m.statusMsg, "#8") {
|
|
t.Errorf("expected statusMsg to mention #8, got %q", m.statusMsg)
|
|
}
|
|
// Run the prDeletedCmd.
|
|
resultCmds[0]()
|
|
if capturedExtra == nil {
|
|
t.Fatal("expected mergeStateJSONFn to be called on PR deleted")
|
|
}
|
|
if v, ok := capturedExtra["pr"]; !ok || v != "" {
|
|
t.Errorf("expected pr=\"\" (clear) in state JSON, got %v", capturedExtra)
|
|
}
|
|
}
|
|
|
|
// ── issue edited / spec-plan sync (Fix 2) ────────────────────────────────────
|
|
|
|
func TestDispatch_IssueEvent_Edited_QueuesSpecPlanSync(t *testing.T) {
|
|
origMerge := mergeStateJSONFn
|
|
defer func() { mergeStateJSONFn = origMerge }()
|
|
|
|
var capturedExtra map[string]any
|
|
mergeStateJSONFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int, extra map[string]any) error {
|
|
capturedExtra = extra
|
|
return nil
|
|
}
|
|
|
|
origRepo := resolveGiteaRepoFn
|
|
defer func() { resolveGiteaRepoFn = origRepo }()
|
|
resolveGiteaRepoFn = func() (*giteaRepoCtx, error) {
|
|
return &giteaRepoCtx{client: &gitea.Client{}, owner: "o", repo: "r"}, nil
|
|
}
|
|
|
|
body := "<!-- spx:spec=docs/spec/feat-8.md --> <!-- spx:plan=docs/plan/feat-8.md -->"
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
m, resultCmds := handleIssueEvent(m, webhook.IssueEvent{
|
|
Action: "edited",
|
|
IssueNumber: 8,
|
|
Body: body,
|
|
}, cmds)
|
|
if len(resultCmds) == 0 {
|
|
t.Fatal("expected at least one cmd (syncIssueSpecPlanCmd) for edited action")
|
|
}
|
|
if !strings.Contains(m.statusMsg, "edited") {
|
|
t.Errorf("expected statusMsg to mention edited, got %q", m.statusMsg)
|
|
}
|
|
// Run the sync cmd.
|
|
resultCmds[0]()
|
|
if capturedExtra == nil {
|
|
t.Fatal("expected mergeStateJSONFn to be called with spec/plan")
|
|
}
|
|
if v, ok := capturedExtra["specFile"]; !ok || !strings.Contains(fmt.Sprint(v), "feat-8.md") {
|
|
t.Errorf("expected specFile to contain feat-8.md, got %v", capturedExtra)
|
|
}
|
|
if v, ok := capturedExtra["planFile"]; !ok || !strings.Contains(fmt.Sprint(v), "feat-8.md") {
|
|
t.Errorf("expected planFile to contain feat-8.md, got %v", capturedExtra)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_IssueEvent_Edited_NoMarkers_NoCmd(t *testing.T) {
|
|
m := Model{webhookCh: make(chan webhook.Event, 1)}
|
|
var cmds []tea.Cmd
|
|
_, resultCmds := handleIssueEvent(m, webhook.IssueEvent{
|
|
Action: "edited",
|
|
IssueNumber: 9,
|
|
Body: "no markers here",
|
|
}, cmds)
|
|
// syncIssueSpecPlanCmd is queued but returns nil when run (no markers match).
|
|
if len(resultCmds) != 1 {
|
|
t.Fatalf("expected 1 cmd (syncIssueSpecPlanCmd, no-op), got %d", len(resultCmds))
|
|
}
|
|
// Run it — must not panic and must return nil (no gitea call needed).
|
|
msg := resultCmds[0]()
|
|
if msg != nil {
|
|
t.Errorf("expected nil msg when no markers found, got %T", msg)
|
|
}
|
|
}
|