11
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user