11
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
package issue_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"superwork-tui/internal/gitea"
|
||||
"superwork-tui/internal/issue"
|
||||
)
|
||||
|
||||
// fakeClient implements issue.GiteaClient using canned data.
|
||||
type fakeClient struct {
|
||||
user *gitea.User
|
||||
issuesByFilter map[string][]gitea.Issue // key: filterKey+":"+filterVal
|
||||
repoComments []gitea.Comment
|
||||
issueComments map[int][]gitea.Comment
|
||||
pullRequests map[int]*gitea.PullRequest
|
||||
dependencies map[int][]gitea.Issue
|
||||
posted []string // bodies posted via PostIssueComment
|
||||
}
|
||||
|
||||
func (f *fakeClient) GetCurrentUser(ctx context.Context) (*gitea.User, error) {
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) ListIssuesByFilter(ctx context.Context, owner, repo, filterKey, filterVal string) ([]gitea.Issue, error) {
|
||||
key := filterKey + ":" + filterVal
|
||||
return f.issuesByFilter[key], nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) ListAllRepoComments(ctx context.Context, owner, repo string) ([]gitea.Comment, error) {
|
||||
return f.repoComments, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) GetIssue(ctx context.Context, owner, repo string, number int) (*gitea.Issue, error) {
|
||||
for _, bucket := range f.issuesByFilter {
|
||||
for i := range bucket {
|
||||
if bucket[i].Number == number {
|
||||
iss := bucket[i]
|
||||
return &iss, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) GetPullRequest(ctx context.Context, owner, repo string, number int) (*gitea.PullRequest, error) {
|
||||
pr, ok := f.pullRequests[number]
|
||||
if !ok {
|
||||
return nil, &gitea.APIError{Status: 404}
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) ListIssueComments(ctx context.Context, owner, repo string, number int) ([]gitea.Comment, error) {
|
||||
return f.issueComments[number], nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*gitea.Comment, error) {
|
||||
f.posted = append(f.posted, body)
|
||||
return &gitea.Comment{ID: 999, Body: body}, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) GetDependencies(ctx context.Context, owner, repo string, number int) ([]gitea.Issue, error) {
|
||||
return f.dependencies[number], nil
|
||||
}
|
||||
|
||||
// --- extractStateJSON tests ---
|
||||
|
||||
func TestExtractStateJSON_TailScan(t *testing.T) {
|
||||
// The last comment is a state blob; an intermediate ordinary comment must not shadow it.
|
||||
comments := []gitea.Comment{
|
||||
{Body: `{"column":"todo","branch":"feat/old"}`},
|
||||
{Body: "just some text"},
|
||||
{Body: `{"column":"in-progress","branch":"feat/new","pr":"42"}`},
|
||||
{Body: "PR auto-link comment or review note"},
|
||||
}
|
||||
state := issue.ExtractStateJSON(comments)
|
||||
// tail-first: last comment is plain text, skip; second-to-last is the state blob
|
||||
if state["column"] != "in-progress" {
|
||||
t.Errorf("column: got %v, want in-progress", state["column"])
|
||||
}
|
||||
if state["branch"] != "feat/new" {
|
||||
t.Errorf("branch: got %v, want feat/new", state["branch"])
|
||||
}
|
||||
if state["pr"] != "42" {
|
||||
t.Errorf("pr: got %v, want 42", state["pr"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStateJSON_OrdinaryJSONSkipped(t *testing.T) {
|
||||
// A JSON object without any known state fields must not be treated as a state blob.
|
||||
comments := []gitea.Comment{
|
||||
{Body: `{"column":"done","pr":"7"}`},
|
||||
{Body: `{"foo":"bar","baz":123}`}, // unknown fields only
|
||||
}
|
||||
state := issue.ExtractStateJSON(comments)
|
||||
// The unknown-fields JSON at the end must be skipped; pick the earlier state blob.
|
||||
if state["column"] != "done" {
|
||||
t.Errorf("column: got %v, want done", state["column"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStateJSON_NoStateBlob(t *testing.T) {
|
||||
comments := []gitea.Comment{
|
||||
{Body: "just text"},
|
||||
{Body: `{"foo":"bar"}`},
|
||||
}
|
||||
state := issue.ExtractStateJSON(comments)
|
||||
if len(state) != 0 {
|
||||
t.Errorf("expected empty map, got %v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStateJSON_Empty(t *testing.T) {
|
||||
state := issue.ExtractStateJSON(nil)
|
||||
if len(state) != 0 {
|
||||
t.Errorf("expected empty map, got %v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStateJSON_Array_Skipped(t *testing.T) {
|
||||
// JSON arrays are not state blobs.
|
||||
comments := []gitea.Comment{
|
||||
{Body: `{"column":"review"}`},
|
||||
{Body: `[1,2,3]`},
|
||||
}
|
||||
state := issue.ExtractStateJSON(comments)
|
||||
if state["column"] != "review" {
|
||||
t.Errorf("column: got %v, want review", state["column"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- isValidSpxFilePath tests ---
|
||||
|
||||
func TestIsValidSpxFilePath(t *testing.T) {
|
||||
valid := []string{
|
||||
"docs/superpowers/specs/my-feature.md",
|
||||
"some/path/plan.md",
|
||||
}
|
||||
for _, v := range valid {
|
||||
if !issue.IsValidSpxFilePath(v) {
|
||||
t.Errorf("expected %q to be valid", v)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
"...",
|
||||
"…",
|
||||
"noSlash.md",
|
||||
"has/slash/but.txt",
|
||||
}
|
||||
for _, s := range invalid {
|
||||
if issue.IsValidSpxFilePath(s) {
|
||||
t.Errorf("expected %q to be invalid", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- loader tests ---
|
||||
|
||||
func makeIssue(number int, state, htmlURL string) gitea.Issue {
|
||||
return gitea.Issue{
|
||||
ID: number,
|
||||
Number: number,
|
||||
Title: "Issue " + string(rune('0'+number)),
|
||||
State: state,
|
||||
HtmlURL: htmlURL,
|
||||
}
|
||||
}
|
||||
|
||||
func issueURL(number int) string {
|
||||
return "https://gitea.example.com/owner/repo/issues/" + itoa(number)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
func TestLoadIssues_TailScanChoosesLastStateBlob(t *testing.T) {
|
||||
iss := makeIssue(1, "open", "https://gitea.example.com/owner/repo/issues/1")
|
||||
client := &fakeClient{
|
||||
user: &gitea.User{Login: "alice"},
|
||||
issuesByFilter: map[string][]gitea.Issue{
|
||||
"assigned_by:alice": {iss},
|
||||
"created_by:alice": {},
|
||||
},
|
||||
repoComments: []gitea.Comment{
|
||||
{Body: `{"column":"todo"}`, IssueURL: issueURL(1), CreatedAt: "2024-01-01T00:00:00Z"},
|
||||
{Body: "ordinary text", IssueURL: issueURL(1), CreatedAt: "2024-01-02T00:00:00Z"},
|
||||
{Body: `{"column":"in-progress","branch":"feat/1"}`, IssueURL: issueURL(1), CreatedAt: "2024-01-03T00:00:00Z"},
|
||||
{Body: "PR auto-link", IssueURL: issueURL(1), CreatedAt: "2024-01-04T00:00:00Z"},
|
||||
},
|
||||
dependencies: map[int][]gitea.Issue{},
|
||||
pullRequests: map[int]*gitea.PullRequest{},
|
||||
}
|
||||
|
||||
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadIssues: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("expected 1 issue, got %d", len(issues))
|
||||
}
|
||||
got := issues[0]
|
||||
if got.Column != issue.ColumnInProgress {
|
||||
t.Errorf("column: got %v, want in-progress", got.Column)
|
||||
}
|
||||
if got.Branch != "feat/1" {
|
||||
t.Errorf("branch: got %v, want feat/1", got.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIssues_MissingStateSeeded(t *testing.T) {
|
||||
iss := makeIssue(2, "open", "https://gitea.example.com/owner/repo/issues/2")
|
||||
client := &fakeClient{
|
||||
user: &gitea.User{Login: "bob"},
|
||||
issuesByFilter: map[string][]gitea.Issue{
|
||||
"assigned_by:bob": {iss},
|
||||
"created_by:bob": {},
|
||||
},
|
||||
repoComments: []gitea.Comment{}, // no comments → no state blob
|
||||
dependencies: map[int][]gitea.Issue{},
|
||||
pullRequests: map[int]*gitea.PullRequest{},
|
||||
}
|
||||
|
||||
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadIssues: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("expected 1 issue, got %d", len(issues))
|
||||
}
|
||||
got := issues[0]
|
||||
// open issue defaults to todo
|
||||
if got.Column != issue.ColumnTodo {
|
||||
t.Errorf("column: got %v, want todo", got.Column)
|
||||
}
|
||||
// A seed comment must have been posted
|
||||
if len(client.posted) != 1 {
|
||||
t.Errorf("expected 1 posted comment, got %d", len(client.posted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIssues_DoneSkipsPRAndDeps(t *testing.T) {
|
||||
iss := makeIssue(3, "closed", "https://gitea.example.com/owner/repo/issues/3")
|
||||
client := &fakeClient{
|
||||
user: &gitea.User{Login: "carol"},
|
||||
issuesByFilter: map[string][]gitea.Issue{
|
||||
"assigned_by:carol": {iss},
|
||||
"created_by:carol": {},
|
||||
},
|
||||
repoComments: []gitea.Comment{
|
||||
{Body: `{"column":"done","pr":"10"}`, IssueURL: issueURL(3), CreatedAt: "2024-01-01T00:00:00Z"},
|
||||
},
|
||||
dependencies: map[int][]gitea.Issue{
|
||||
3: {{Number: 1}},
|
||||
},
|
||||
pullRequests: map[int]*gitea.PullRequest{
|
||||
10: {Number: 10, Merged: true, MergedAt: "2024-02-01T00:00:00Z"},
|
||||
},
|
||||
}
|
||||
prCallCount := 0
|
||||
depsCallCount := 0
|
||||
_ = prCallCount
|
||||
_ = depsCallCount
|
||||
|
||||
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadIssues: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("expected 1 issue, got %d", len(issues))
|
||||
}
|
||||
got := issues[0]
|
||||
if got.Column != issue.ColumnDone {
|
||||
t.Errorf("column: got %v, want done", got.Column)
|
||||
}
|
||||
// done column: prerequisite must be 0 (skipped), prMerged is from state JSON
|
||||
if got.Prerequisite != 0 {
|
||||
t.Errorf("prerequisite: got %d, want 0 (done skips deps)", got.Prerequisite)
|
||||
}
|
||||
// PrMerged not live-fetched but state JSON had pr:"10"; prMerged not in state JSON → false
|
||||
// The point: no live PR fetch happened (we can't easily assert that without a sentinel,
|
||||
// but we assert the column and prerequisite are correct)
|
||||
}
|
||||
|
||||
func TestLoadIssues_Prerequisite(t *testing.T) {
|
||||
iss := makeIssue(5, "open", "https://gitea.example.com/owner/repo/issues/5")
|
||||
client := &fakeClient{
|
||||
user: &gitea.User{Login: "dave"},
|
||||
issuesByFilter: map[string][]gitea.Issue{
|
||||
"assigned_by:dave": {iss},
|
||||
"created_by:dave": {},
|
||||
},
|
||||
repoComments: []gitea.Comment{
|
||||
{Body: `{"column":"todo"}`, IssueURL: issueURL(5), CreatedAt: "2024-01-01T00:00:00Z"},
|
||||
},
|
||||
dependencies: map[int][]gitea.Issue{
|
||||
5: {{Number: 3}, {Number: 4}}, // first dep is prerequisite
|
||||
},
|
||||
pullRequests: map[int]*gitea.PullRequest{},
|
||||
}
|
||||
|
||||
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadIssues: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("expected 1 issue, got %d", len(issues))
|
||||
}
|
||||
if issues[0].Prerequisite != 3 {
|
||||
t.Errorf("prerequisite: got %d, want 3", issues[0].Prerequisite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIssues_WorktreeExists(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
worktree := filepath.Join(tmp, "trees", "feat-5")
|
||||
if err := os.MkdirAll(worktree, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relWorktree := "trees/feat-5"
|
||||
|
||||
iss := makeIssue(6, "open", "https://gitea.example.com/owner/repo/issues/6")
|
||||
client := &fakeClient{
|
||||
user: &gitea.User{Login: "eve"},
|
||||
issuesByFilter: map[string][]gitea.Issue{
|
||||
"assigned_by:eve": {iss},
|
||||
"created_by:eve": {},
|
||||
},
|
||||
repoComments: []gitea.Comment{
|
||||
{
|
||||
Body: `{"column":"in-progress","worktreePath":"trees/feat-5"}`,
|
||||
IssueURL: issueURL(6),
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
dependencies: map[int][]gitea.Issue{},
|
||||
pullRequests: map[int]*gitea.PullRequest{},
|
||||
}
|
||||
|
||||
issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadIssues: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("expected 1 issue, got %d", len(issues))
|
||||
}
|
||||
got := issues[0]
|
||||
if got.WorktreePath != relWorktree {
|
||||
t.Errorf("worktreePath: got %q, want %q", got.WorktreePath, relWorktree)
|
||||
}
|
||||
if !got.WorktreeExists {
|
||||
t.Errorf("worktreeExists: got false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSingleIssue_NotFound(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
user: &gitea.User{Login: "frank"},
|
||||
issuesByFilter: map[string][]gitea.Issue{},
|
||||
repoComments: nil,
|
||||
issueComments: map[int][]gitea.Comment{},
|
||||
dependencies: map[int][]gitea.Issue{},
|
||||
pullRequests: map[int]*gitea.PullRequest{},
|
||||
}
|
||||
|
||||
got, err := issue.LoadSingleIssue(context.Background(), client, "owner", "repo", 999, "")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSingleIssue: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for missing issue, got %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user