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