Files
superwork/tui/internal/issue/loader.go
T
2026-06-23 05:02:15 +08:00

394 lines
11 KiB
Go

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