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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"superwork-tui/internal/gitea"
|
||||
)
|
||||
|
||||
// GiteaClient is the minimal interface required by this package.
|
||||
// *gitea.Client satisfies it at compile time.
|
||||
type GiteaClient interface {
|
||||
GetCurrentUser(ctx context.Context) (*gitea.User, error)
|
||||
ListIssuesByFilter(ctx context.Context, owner, repo, filterKey, filterVal string) ([]gitea.Issue, error)
|
||||
ListAllRepoComments(ctx context.Context, owner, repo string) ([]gitea.Comment, error)
|
||||
GetIssue(ctx context.Context, owner, repo string, number int) (*gitea.Issue, error)
|
||||
GetPullRequest(ctx context.Context, owner, repo string, number int) (*gitea.PullRequest, error)
|
||||
ListIssueComments(ctx context.Context, owner, repo string, number int) ([]gitea.Comment, error)
|
||||
PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*gitea.Comment, error)
|
||||
GetDependencies(ctx context.Context, owner, repo string, number int) ([]gitea.Issue, error)
|
||||
}
|
||||
|
||||
// compile-time check that *gitea.Client satisfies GiteaClient.
|
||||
var _ GiteaClient = (*gitea.Client)(nil)
|
||||
|
||||
// IsValidSpxFilePath reports whether v is a real workspace-relative markdown
|
||||
// path (contains "/", ends with ".md", not a placeholder like "..." or "…").
|
||||
func IsValidSpxFilePath(v string) bool {
|
||||
return v != "" &&
|
||||
strings.Contains(v, "/") &&
|
||||
strings.HasSuffix(v, ".md") &&
|
||||
v != "..." &&
|
||||
v != "…"
|
||||
}
|
||||
|
||||
// isValidPrDiffFilePath matches the narrower rule used for prDiffFile.
|
||||
func isValidPrDiffFilePath(v string) bool {
|
||||
return strings.HasPrefix(v, "docs/pr-diff/") && strings.HasSuffix(v, ".md") && !strings.Contains(v, " ")
|
||||
}
|
||||
|
||||
func defaultColumn(state string) Column {
|
||||
if state == "open" {
|
||||
return ColumnTodo
|
||||
}
|
||||
return ColumnDone
|
||||
}
|
||||
|
||||
// parseState extracts all issue state fields from the comment bucket.
|
||||
// Returns zero values for absent or invalid fields.
|
||||
func parseState(comments []gitea.Comment) (col Column, fields map[string]any) {
|
||||
state := ExtractStateJSON(comments)
|
||||
|
||||
colStr, _ := state["column"].(string)
|
||||
if validColumns[Column(colStr)] {
|
||||
col = Column(colStr)
|
||||
}
|
||||
return col, state
|
||||
}
|
||||
|
||||
func stringField(state map[string]any, key string) string {
|
||||
v, _ := state[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func boolField(state map[string]any, key string) *bool {
|
||||
v, ok := state[key].(bool)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// numberFromIssueURL parses the trailing number from a Gitea issue_url like
|
||||
// ".../issues/42".
|
||||
func numberFromIssueURL(issueURL string) (int, bool) {
|
||||
parts := strings.Split(issueURL, "/")
|
||||
if len(parts) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(parts[len(parts)-1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// groupComments groups repo-wide comments by issue number, sorted ascending by
|
||||
// creation time (Gitea returns them that way, but we sort to be safe).
|
||||
func groupComments(comments []gitea.Comment) map[int][]gitea.Comment {
|
||||
buckets := make(map[int][]gitea.Comment)
|
||||
for _, c := range comments {
|
||||
n, ok := numberFromIssueURL(c.IssueURL)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
buckets[n] = append(buckets[n], c)
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
// mergeIssues deduplicates two issue lists by number, preferring first seen.
|
||||
func mergeIssues(a, b []gitea.Issue) []gitea.Issue {
|
||||
seen := make(map[int]bool, len(a))
|
||||
out := make([]gitea.Issue, 0, len(a)+len(b))
|
||||
for _, iss := range append(a, b...) {
|
||||
if !seen[iss.Number] {
|
||||
seen[iss.Number] = true
|
||||
out = append(out, iss)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildIssue assembles an Issue domain object from a Gitea issue + its comment
|
||||
// bucket, resolves live PR status and prerequisite, and seeds a default state
|
||||
// comment when the bucket has no state blob.
|
||||
func buildIssue(
|
||||
ctx context.Context,
|
||||
client GiteaClient,
|
||||
owner, repo, workspaceRoot string,
|
||||
raw gitea.Issue,
|
||||
comments []gitea.Comment,
|
||||
prerequisite int,
|
||||
liveMerged *bool,
|
||||
liveMergedAt string,
|
||||
) (*Issue, error) {
|
||||
col, state := parseState(comments)
|
||||
|
||||
// When no state blob exists, fall back to issue.state and seed a comment.
|
||||
if col == "" {
|
||||
col = defaultColumn(raw.State)
|
||||
seed, _ := json.Marshal(map[string]string{"column": string(col)})
|
||||
if _, err := client.PostIssueComment(ctx, owner, repo, raw.Number, string(seed)); err != nil {
|
||||
log.Printf("[superwork] failed to seed state comment on %s/%s#%d: %v", owner, repo, raw.Number, err)
|
||||
}
|
||||
}
|
||||
|
||||
specFile := stringField(state, "specFile")
|
||||
if !IsValidSpxFilePath(specFile) {
|
||||
specFile = ""
|
||||
}
|
||||
planFile := stringField(state, "planFile")
|
||||
if !IsValidSpxFilePath(planFile) {
|
||||
planFile = ""
|
||||
}
|
||||
prDiffFile := stringField(state, "prDiffFile")
|
||||
if !isValidPrDiffFilePath(prDiffFile) {
|
||||
prDiffFile = ""
|
||||
}
|
||||
|
||||
// Resolve prMerged: live query wins, fall back to state JSON.
|
||||
prMerged := false
|
||||
if liveMerged != nil {
|
||||
prMerged = *liveMerged
|
||||
} else if v := boolField(state, "prMerged"); v != nil {
|
||||
prMerged = *v
|
||||
}
|
||||
|
||||
mergedAt := liveMergedAt
|
||||
if mergedAt == "" {
|
||||
mergedAt = stringField(state, "prMergedAt")
|
||||
}
|
||||
|
||||
worktreePath := stringField(state, "worktreePath")
|
||||
worktreeExists := false
|
||||
if worktreePath != "" && workspaceRoot != "" {
|
||||
if _, err := os.Stat(filepath.Join(workspaceRoot, worktreePath)); err == nil {
|
||||
worktreeExists = true
|
||||
}
|
||||
}
|
||||
|
||||
iss := &Issue{
|
||||
Number: raw.Number,
|
||||
Title: raw.Title,
|
||||
Column: col,
|
||||
SessionID: stringField(state, "sessionId"),
|
||||
ProfilePath: stringField(state, "profilePath"),
|
||||
TestProfilePath: stringField(state, "testProfilePath"),
|
||||
SpecFile: specFile,
|
||||
PlanFile: planFile,
|
||||
PrDiffFile: prDiffFile,
|
||||
PR: stringField(state, "pr"),
|
||||
PrMerged: prMerged,
|
||||
PrMergedAt: mergedAt,
|
||||
Branch: stringField(state, "branch"),
|
||||
WorktreePath: worktreePath,
|
||||
WorktreeExists: worktreeExists,
|
||||
ImplementStatus: stringField(state, "implementStatus"),
|
||||
ImplementSessionID: stringField(state, "implementSessionId"),
|
||||
ReviewSessionID: stringField(state, "reviewSessionId"),
|
||||
TestSessionID: stringField(state, "testSessionId"),
|
||||
HTMLURL: raw.HtmlURL,
|
||||
Prerequisite: prerequisite,
|
||||
Color: stringField(state, "color"),
|
||||
AutoReview: boolField(state, "autoReview"),
|
||||
}
|
||||
return iss, nil
|
||||
}
|
||||
|
||||
// fetchPRStatus resolves live merged/mergedAt for a PR number string.
|
||||
// Returns nil, "" on any failure so callers fall back to state JSON.
|
||||
func fetchPRStatus(ctx context.Context, client GiteaClient, owner, repo, prStr string) (*bool, string) {
|
||||
if prStr == "" {
|
||||
return nil, ""
|
||||
}
|
||||
n, err := strconv.Atoi(prStr)
|
||||
if err != nil || n <= 0 {
|
||||
return nil, ""
|
||||
}
|
||||
pr, err := client.GetPullRequest(ctx, owner, repo, n)
|
||||
if err != nil || pr == nil {
|
||||
return nil, ""
|
||||
}
|
||||
merged := pr.Merged
|
||||
return &merged, pr.MergedAt
|
||||
}
|
||||
|
||||
// LoadIssues loads all issues assigned to or created by the current user,
|
||||
// resolves state from comment blobs, and enriches with live PR and dependency
|
||||
// data (skipped for done-column issues).
|
||||
func LoadIssues(ctx context.Context, client GiteaClient, owner, repo, workspaceRoot string) ([]Issue, error) {
|
||||
// Kick off user + repo-wide comments in parallel.
|
||||
userCh := make(chan *gitea.User, 1)
|
||||
userErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
u, err := client.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
userErrCh <- err
|
||||
return
|
||||
}
|
||||
userCh <- u
|
||||
}()
|
||||
|
||||
commentsCh := make(chan []gitea.Comment, 1)
|
||||
commentsErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
cs, err := client.ListAllRepoComments(ctx, owner, repo)
|
||||
if err != nil {
|
||||
commentsErrCh <- err
|
||||
return
|
||||
}
|
||||
commentsCh <- cs
|
||||
}()
|
||||
|
||||
// Wait for user before firing filter queries.
|
||||
var user *gitea.User
|
||||
select {
|
||||
case u := <-userCh:
|
||||
user = u
|
||||
case err := <-userErrCh:
|
||||
return nil, fmt.Errorf("get current user: %w", err)
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Assigned + created in parallel while comments may still be in flight.
|
||||
var assigned, created []gitea.Issue
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
assigned, err = client.ListIssuesByFilter(egCtx, owner, repo, "assigned_by", user.Login)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
created, err = client.ListIssuesByFilter(egCtx, owner, repo, "created_by", user.Login)
|
||||
return err
|
||||
})
|
||||
|
||||
var allComments []gitea.Comment
|
||||
eg.Go(func() error {
|
||||
select {
|
||||
case cs := <-commentsCh:
|
||||
allComments = cs
|
||||
return nil
|
||||
case err := <-commentsErrCh:
|
||||
return fmt.Errorf("list repo comments: %w", err)
|
||||
case <-egCtx.Done():
|
||||
return egCtx.Err()
|
||||
}
|
||||
})
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
issues := mergeIssues(assigned, created)
|
||||
buckets := groupComments(allComments)
|
||||
|
||||
// Pre-compute columns to decide which issues need live PR/deps queries.
|
||||
cols := make([]Column, len(issues))
|
||||
for i, iss := range issues {
|
||||
bucket := buckets[iss.Number]
|
||||
col, _ := parseState(bucket)
|
||||
if col == "" {
|
||||
col = defaultColumn(iss.State)
|
||||
}
|
||||
cols[i] = col
|
||||
}
|
||||
|
||||
// Concurrently fetch prerequisites and live PR status for non-done issues.
|
||||
prerequisites := make([]int, len(issues))
|
||||
liveMergedSlice := make([]*bool, len(issues))
|
||||
liveMergedAtSlice := make([]string, len(issues))
|
||||
|
||||
eg2, eg2Ctx := errgroup.WithContext(ctx)
|
||||
for i := range issues {
|
||||
i := i
|
||||
iss := issues[i]
|
||||
if cols[i] == ColumnDone {
|
||||
continue
|
||||
}
|
||||
eg2.Go(func() error {
|
||||
deps, err := client.GetDependencies(eg2Ctx, owner, repo, iss.Number)
|
||||
if err == nil && len(deps) > 0 {
|
||||
prerequisites[i] = deps[0].Number
|
||||
}
|
||||
return nil // failures are non-fatal
|
||||
})
|
||||
eg2.Go(func() error {
|
||||
bucket := buckets[iss.Number]
|
||||
_, state := parseState(bucket)
|
||||
prStr := stringField(state, "pr")
|
||||
m, at := fetchPRStatus(eg2Ctx, client, owner, repo, prStr)
|
||||
liveMergedSlice[i] = m
|
||||
liveMergedAtSlice[i] = at
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := eg2.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Issue, 0, len(issues))
|
||||
for i, raw := range issues {
|
||||
iss, err := buildIssue(
|
||||
ctx, client, owner, repo, workspaceRoot,
|
||||
raw,
|
||||
buckets[raw.Number],
|
||||
prerequisites[i],
|
||||
liveMergedSlice[i],
|
||||
liveMergedAtSlice[i],
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build issue %d: %w", raw.Number, err)
|
||||
}
|
||||
out = append(out, *iss)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LoadSingleIssue loads one issue by number for incremental board updates.
|
||||
// Returns (nil, nil) when the issue does not exist (404).
|
||||
func LoadSingleIssue(ctx context.Context, client GiteaClient, owner, repo string, number int, workspaceRoot string) (*Issue, error) {
|
||||
raw, err := client.GetIssue(ctx, owner, repo, number)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get issue %d: %w", number, err)
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var comments []gitea.Comment
|
||||
var prerequisite int
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
comments, err = client.ListIssueComments(egCtx, owner, repo, number)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
deps, err := client.GetDependencies(egCtx, owner, repo, number)
|
||||
if err == nil && len(deps) > 0 {
|
||||
prerequisite = deps[0].Number
|
||||
}
|
||||
return nil // non-fatal
|
||||
})
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, fmt.Errorf("load single issue %d: %w", number, err)
|
||||
}
|
||||
|
||||
_, state := parseState(comments)
|
||||
prStr := stringField(state, "pr")
|
||||
liveMerged, liveMergedAt := fetchPRStatus(ctx, client, owner, repo, prStr)
|
||||
|
||||
return buildIssue(ctx, client, owner, repo, workspaceRoot, *raw, comments, prerequisite, liveMerged, liveMergedAt)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package issue
|
||||
|
||||
// Column is the kanban board column identifier.
|
||||
type Column string
|
||||
|
||||
const (
|
||||
ColumnTodo Column = "todo"
|
||||
ColumnInProgress Column = "in-progress"
|
||||
ColumnReview Column = "review"
|
||||
ColumnDone Column = "done"
|
||||
)
|
||||
|
||||
var validColumns = map[Column]bool{
|
||||
ColumnTodo: true,
|
||||
ColumnInProgress: true,
|
||||
ColumnReview: true,
|
||||
ColumnDone: true,
|
||||
}
|
||||
|
||||
// Issue is the domain model for a kanban card, mirroring the TypeScript Issue
|
||||
// type in types.ts (youtrack fields omitted: source, externalId, attachments).
|
||||
// JSON tags use camelCase to match the state blob format in comments.
|
||||
type Issue struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Column Column `json:"column"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
ProfilePath string `json:"profilePath,omitempty"`
|
||||
TestProfilePath string `json:"testProfilePath,omitempty"`
|
||||
SpecFile string `json:"specFile,omitempty"`
|
||||
PlanFile string `json:"planFile,omitempty"`
|
||||
PrDiffFile string `json:"prDiffFile,omitempty"`
|
||||
PR string `json:"pr,omitempty"`
|
||||
PrMerged bool `json:"prMerged,omitempty"`
|
||||
PrMergedAt string `json:"prMergedAt,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
WorktreePath string `json:"worktreePath,omitempty"`
|
||||
WorktreeExists bool `json:"worktreeExists,omitempty"`
|
||||
ImplementStatus string `json:"implementStatus,omitempty"`
|
||||
ImplementSessionID string `json:"implementSessionId,omitempty"`
|
||||
ReviewSessionID string `json:"reviewSessionId,omitempty"`
|
||||
TestSessionID string `json:"testSessionId,omitempty"`
|
||||
HTMLURL string `json:"htmlUrl,omitempty"`
|
||||
Prerequisite int `json:"prerequisite,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
AutoReview *bool `json:"autoReview,omitempty"`
|
||||
BrainstormTabOpen bool `json:"brainstormTabOpen,omitempty"`
|
||||
ImplementTabOpen bool `json:"implementTabOpen,omitempty"`
|
||||
ReviewTabOpen bool `json:"reviewTabOpen,omitempty"`
|
||||
TestTabOpen bool `json:"testTabOpen,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"superwork-tui/internal/gitea"
|
||||
)
|
||||
|
||||
// knownStateFields is the authoritative set used to distinguish a state-JSON
|
||||
// comment from an ordinary JSON or text comment. Must match stateJson.ts.
|
||||
var knownStateFields = map[string]bool{
|
||||
"column": true,
|
||||
"sessionId": true,
|
||||
"implementSessionId": true,
|
||||
"reviewSessionId": true,
|
||||
"testSessionId": true,
|
||||
"profilePath": true,
|
||||
"testProfilePath": true,
|
||||
"specFile": true,
|
||||
"planFile": true,
|
||||
"prDiffFile": true,
|
||||
"pr": true,
|
||||
"prMerged": true,
|
||||
"prMergedAt": true,
|
||||
"branch": true,
|
||||
"worktreePath": true,
|
||||
"implementStatus": true,
|
||||
"color": true,
|
||||
"autoReview": true,
|
||||
}
|
||||
|
||||
// ExtractStateJSON scans comments tail-first and returns the first comment
|
||||
// body that parses as a JSON object containing at least one known state field.
|
||||
// Ordinary text and JSON comments without known fields are skipped so they
|
||||
// cannot shadow a real state blob. Returns an empty map when nothing matches.
|
||||
//
|
||||
// Exported so tests can call it without a network client.
|
||||
func ExtractStateJSON(comments []gitea.Comment) map[string]any {
|
||||
for i := len(comments) - 1; i >= 0; i-- {
|
||||
body := comments[i].Body
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
for k := range obj {
|
||||
if knownStateFields[k] {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// stateCommenter is the subset of gitea.Client used by state-JSON operations.
|
||||
type stateCommenter interface {
|
||||
ListIssueComments(ctx context.Context, owner, repo string, number int) ([]gitea.Comment, error)
|
||||
PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*gitea.Comment, error)
|
||||
}
|
||||
|
||||
// ReadStateJSON fetches issue comments and extracts the latest state blob.
|
||||
func ReadStateJSON(ctx context.Context, client stateCommenter, owner, repo string, number int) (map[string]any, error) {
|
||||
comments, err := client.ListIssueComments(ctx, owner, repo, number)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list comments for issue %d: %w", number, err)
|
||||
}
|
||||
return ExtractStateJSON(comments), nil
|
||||
}
|
||||
|
||||
// MergeStateJSON reads the current state blob, merges extra on top, and posts
|
||||
// a new comment containing the merged JSON.
|
||||
func MergeStateJSON(ctx context.Context, client stateCommenter, owner, repo string, number int, extra map[string]any) error {
|
||||
current, err := ReadStateJSON(ctx, client, owner, repo, number)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return postMerged(ctx, client, owner, repo, number, current, extra)
|
||||
}
|
||||
|
||||
// MergeStateJSONGuarded is like MergeStateJSON but protects done-column issues
|
||||
// from having their column overwritten by a stale automated update. When the
|
||||
// protection strips the only incoming field, no comment is posted.
|
||||
func MergeStateJSONGuarded(
|
||||
ctx context.Context,
|
||||
client stateCommenter,
|
||||
owner, repo string,
|
||||
number int,
|
||||
extra map[string]any,
|
||||
protectDoneColumn bool,
|
||||
) (posted bool, protectedDoneColumn bool, err error) {
|
||||
current, err := ReadStateJSON(ctx, client, owner, repo, number)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
merged := make(map[string]any, len(extra))
|
||||
for k, v := range extra {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
if protectDoneColumn {
|
||||
if col, ok := current["column"]; ok && col == "done" {
|
||||
if incomingCol, has := merged["column"]; has && incomingCol != "done" {
|
||||
delete(merged, "column")
|
||||
protectedDoneColumn = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(merged) == 0 {
|
||||
return false, protectedDoneColumn, nil
|
||||
}
|
||||
|
||||
if err := postMerged(ctx, client, owner, repo, number, current, merged); err != nil {
|
||||
return false, protectedDoneColumn, err
|
||||
}
|
||||
return true, protectedDoneColumn, nil
|
||||
}
|
||||
|
||||
// postMerged merges current + extra and posts the combined object as a raw
|
||||
// JSON comment body (no fences — matches the TS postIssueComment call in
|
||||
// stateJson.ts which passes JSON.stringify(merged) directly).
|
||||
func postMerged(ctx context.Context, client stateCommenter, owner, repo string, number int, current, extra map[string]any) error {
|
||||
result := make(map[string]any, len(current)+len(extra))
|
||||
for k, v := range current {
|
||||
result[k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
result[k] = v
|
||||
}
|
||||
b, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal state JSON: %w", err)
|
||||
}
|
||||
if _, err := client.PostIssueComment(ctx, owner, repo, number, string(b)); err != nil {
|
||||
return fmt.Errorf("post state comment on issue %d: %w", number, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user