diff --git a/internal/cli/report.go b/internal/cli/report.go index 4ba79a2..dbcf353 100644 --- a/internal/cli/report.go +++ b/internal/cli/report.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "regexp" "strings" @@ -167,47 +168,15 @@ func cmdReport(ctx context.Context, args []string) int { // Track the worst-case outcome so the overall command exit code // reflects reality. Previously cmdReport always returned 0 even // when every create failed, which hid failures from scripts. - anyFailed := false - anyCreated := false - - for i, m := range matches { - fmt.Printf("\n[%d/%d] %s\n", i+1, len(matches), m.Proposal.Title) - if m.IsDuplicate { - fmt.Printf(" duplicate of #%d %q (score %.2f)\n", m.BestExisting.Number, m.BestExisting.Title, m.Score) - fmt.Printf(" → would comment with: %s\n", trim(m.Proposal.Body, 120)) - if confirmReport(yes) { - url, err := report.CommentOnIssue(ctx, m.BestExisting.Number, - "From `i report`:\n\n"+m.Proposal.Body) - if err != nil { - errf("report: comment failed: %v", err) - anyFailed = true - continue - } - anyCreated = true - fmt.Printf(" ✓ commented: %s\n", url) - } - } else { - kept, dropped := report.FilterLabels(m.Proposal.Labels, known) - if len(dropped) > 0 { - fmt.Printf(" (dropping labels not on repo: %v)\n", dropped) - } - p := m.Proposal - p.Labels = kept - fmt.Printf(" new issue\n") - fmt.Printf(" labels: %v\n", p.Labels) - fmt.Printf(" body: %s\n", trim(p.Body, 200)) - if confirmReport(yes) { - url, err := report.CreateIssue(ctx, p) - if err != nil { - errf("report: create failed: %v", err) - anyFailed = true - continue - } - anyCreated = true - fmt.Printf(" ✓ created: %s\n", url) - } - } - } + anyCreated, anyFailed := applyReportMatches( + ctx, + os.Stdout, + matches, + known, + yes, + report.CreateIssue, + report.CommentOnIssue, + ) switch { case anyFailed: // At least one op failed; propagate. Use 3 to match the @@ -222,23 +191,61 @@ func cmdReport(ctx context.Context, args []string) int { return 0 } -// confirmReport is the per-proposal interactive check. Defaults to -// YES because the user explicitly invoked `i report` intending to -// file issues — asking them to type "y" for every proposal after -// they already chose to report is noise. Pressing Enter (or y / yes) -// proceeds; only an explicit n / no / anything-else declines. -func confirmReport(yes bool) bool { - if yes { - return true - } - fmt.Print(" proceed? [Y/n] ") - r := bufio.NewReader(os.Stdin) - line, _ := r.ReadString('\n') - line = strings.TrimSpace(strings.ToLower(line)) - if line == "" || line == "y" || line == "yes" { - return true - } - return false +type reportCreateIssueFunc func(context.Context, report.Proposal) (string, error) +type reportCommentIssueFunc func(context.Context, int, string) (string, error) + +func applyReportMatches( + ctx context.Context, + out io.Writer, + matches []report.Match, + known map[string]bool, + yes bool, + createIssue reportCreateIssueFunc, + commentOnIssue reportCommentIssueFunc, +) (anyCreated, anyFailed bool) { + for i, m := range matches { + fmt.Fprintf(out, "\n[%d/%d] %s\n", i+1, len(matches), m.Proposal.Title) + if m.IsDuplicate { + fmt.Fprintf(out, " duplicate of #%d %q (score %.2f)\n", m.BestExisting.Number, m.BestExisting.Title, m.Score) + fmt.Fprintf(out, " → would comment with: %s\n", trim(m.Proposal.Body, 120)) + if !yes { + fmt.Fprintln(out, " → dry run only; pass --yes to post the comment") + continue + } + url, err := commentOnIssue(ctx, m.BestExisting.Number, "From `i report`:\n\n"+m.Proposal.Body) + if err != nil { + errf("report: comment failed: %v", err) + anyFailed = true + continue + } + anyCreated = true + fmt.Fprintf(out, " ✓ commented: %s\n", url) + continue + } + + kept, dropped := report.FilterLabels(m.Proposal.Labels, known) + if len(dropped) > 0 { + fmt.Fprintf(out, " (dropping labels not on repo: %v)\n", dropped) + } + p := m.Proposal + p.Labels = kept + fmt.Fprintln(out, " new issue") + fmt.Fprintf(out, " labels: %v\n", p.Labels) + fmt.Fprintf(out, " body: %s\n", trim(p.Body, 200)) + if !yes { + fmt.Fprintln(out, " → would create a new issue (pass --yes to execute)") + continue + } + url, err := createIssue(ctx, p) + if err != nil { + errf("report: create failed: %v", err) + anyFailed = true + continue + } + anyCreated = true + fmt.Fprintf(out, " ✓ created: %s\n", url) + } + return anyCreated, anyFailed } func trim(s string, n int) string { diff --git a/internal/cli/report_test.go b/internal/cli/report_test.go index 3fc8b25..d8ea7f3 100644 --- a/internal/cli/report_test.go +++ b/internal/cli/report_test.go @@ -1,6 +1,13 @@ package cli -import "testing" +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/CoreyRDean/intent/internal/report" +) // TestExtractProposals covers the real-world failure modes small local // models have produced when asked to emit issue-proposal JSON. @@ -145,3 +152,145 @@ func TestBuildReportUserInput(t *testing.T) { }) } } + +func TestApplyReportMatches_DryRunSkipsWrites(t *testing.T) { + t.Parallel() + + matches := []report.Match{ + { + Proposal: report.Proposal{ + Title: "Existing bug", + Body: "comment body", + }, + BestExisting: &report.SearchResult{ + Number: 42, + Title: "Existing bug", + }, + Score: 0.91, + IsDuplicate: true, + }, + { + Proposal: report.Proposal{ + Title: "New bug", + Body: "new issue body", + Labels: []string{"bug", "needs-triage"}, + }, + }, + } + + var out bytes.Buffer + createCalls := 0 + commentCalls := 0 + anyCreated, anyFailed := applyReportMatches( + context.Background(), + &out, + matches, + map[string]bool{"bug": true}, + false, + func(context.Context, report.Proposal) (string, error) { + createCalls++ + return "", nil + }, + func(context.Context, int, string) (string, error) { + commentCalls++ + return "", nil + }, + ) + + if anyCreated { + t.Fatal("dry run should not report created work") + } + if anyFailed { + t.Fatal("dry run should not report failures") + } + if createCalls != 0 || commentCalls != 0 { + t.Fatalf("dry run should skip writes, got create=%d comment=%d", createCalls, commentCalls) + } + + got := out.String() + for _, want := range []string{ + `duplicate of #42 "Existing bug"`, + "dry run only; pass --yes to post the comment", + "(dropping labels not on repo: [needs-triage])", + "labels: [bug]", + "would create a new issue (pass --yes to execute)", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected output to contain %q, got:\n%s", want, got) + } + } +} + +func TestApplyReportMatches_YesExecutesWrites(t *testing.T) { + t.Parallel() + + matches := []report.Match{ + { + Proposal: report.Proposal{ + Title: "Existing bug", + Body: "comment body", + }, + BestExisting: &report.SearchResult{ + Number: 42, + Title: "Existing bug", + }, + Score: 0.91, + IsDuplicate: true, + }, + { + Proposal: report.Proposal{ + Title: "New bug", + Body: "new issue body", + Labels: []string{"bug"}, + }, + }, + } + + var out bytes.Buffer + createCalls := 0 + commentCalls := 0 + anyCreated, anyFailed := applyReportMatches( + context.Background(), + &out, + matches, + map[string]bool{"bug": true}, + true, + func(_ context.Context, p report.Proposal) (string, error) { + createCalls++ + if p.Title != "New bug" { + t.Fatalf("unexpected issue title %q", p.Title) + } + return "https://github.com/CoreyRDean/intent/issues/99", nil + }, + func(_ context.Context, number int, body string) (string, error) { + commentCalls++ + if number != 42 { + t.Fatalf("unexpected comment target %d", number) + } + if !strings.Contains(body, "comment body") { + t.Fatalf("unexpected comment body %q", body) + } + return "https://github.com/CoreyRDean/intent/issues/42#issuecomment-1", nil + }, + ) + + if !anyCreated { + t.Fatal("write mode should report created work") + } + if anyFailed { + t.Fatal("write mode should not report failures") + } + if createCalls != 1 || commentCalls != 1 { + t.Fatalf("write mode should execute both actions, got create=%d comment=%d", createCalls, commentCalls) + } + + got := out.String() + for _, want := range []string{ + "✓ commented: https://github.com/CoreyRDean/intent/issues/42#issuecomment-1", + "✓ created: https://github.com/CoreyRDean/intent/issues/99", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected output to contain %q, got:\n%s", want, got) + } + } +}