394 lines
11 KiB
Go
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)
|
|
}
|