gitlink-cli/shortcuts/workflow/review_context.go

538 lines
19 KiB
Go

package workflow
import (
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type ReviewContextOptions struct {
Owner string
Repo string
Number int
FileLimit int
ReviewLimit int
IssueLimit int
LabelLimit int
CommitLimit int
BuildLimit int
IncludeRepo bool
IncludePR bool
IncludeFiles bool
IncludeReviews bool
IncludeIssues bool
IncludeLabels bool
IncludeCommits bool
IncludeCI bool
}
type ReviewContext struct {
Repository string `json:"repository"`
PullRequest int `json:"pull_request"`
Source string `json:"source"`
Sections []string `json:"sections"`
RepositoryInfo map[string]interface{} `json:"repository_info,omitempty"`
PR map[string]interface{} `json:"pr,omitempty"`
Files []map[string]interface{} `json:"files,omitempty"`
Reviews []map[string]interface{} `json:"reviews,omitempty"`
OpenIssues []map[string]interface{} `json:"open_issues,omitempty"`
Labels []map[string]interface{} `json:"labels,omitempty"`
Commits []map[string]interface{} `json:"commits,omitempty"`
Builds []map[string]interface{} `json:"ci_builds,omitempty"`
CISummary *ReviewCISummary `json:"ci_summary,omitempty"`
Notes []ScoringNote `json:"notes,omitempty"`
}
type ReviewCISummary struct {
Total int `json:"total"`
Matched int `json:"matched"`
Unmatched int `json:"unmatched"`
Passed int `json:"passed"`
Failed int `json:"failed"`
Pending int `json:"pending"`
Unknown int `json:"unknown"`
MatchMode string `json:"match_mode"`
HeadBranch string `json:"head_branch,omitempty"`
HeadSHA string `json:"head_sha,omitempty"`
}
func newReviewContextShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "review-context",
Description: "Fetch read-only PR review context from shortcut-backed endpoints",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Pull request number", Required: true},
{Name: "file-limit", Usage: "Maximum changed files to include", Default: "100"},
{Name: "review-limit", Usage: "Maximum reviews to include", Default: "100"},
{Name: "issue-limit", Usage: "Maximum open issues to include", Default: "20"},
{Name: "label-limit", Usage: "Maximum labels to include", Default: "50"},
{Name: "commit-limit", Usage: "Maximum commits to include", Default: "100"},
{Name: "ci-limit", Usage: "Maximum CI builds to include", Default: "20"},
{Name: "include-repo", Usage: "Include repository info", Bool: true, Default: "true"},
{Name: "include-pr", Usage: "Include pull request details", Bool: true, Default: "true"},
{Name: "include-files", Usage: "Include pull request changed files", Bool: true, Default: "true"},
{Name: "include-reviews", Usage: "Include pull request reviews", Bool: true, Default: "true"},
{Name: "include-issues", Usage: "Include open issue context", Bool: true, Default: "true"},
{Name: "include-labels", Usage: "Include issue labels", Bool: true, Default: "true"},
{Name: "include-commits", Usage: "Include pull request commits", Bool: true, Default: "false"},
{Name: "include-ci", Usage: "Include repository CI builds for evidence checks", Bool: true, Default: "false"},
},
Run: runReviewContext,
}
}
func runReviewContext(ctx *common.RuntimeContext) error {
number, err := parseIntArg(ctx.Arg("number"), 0, "number")
if err != nil {
return err
}
if number <= 0 {
return fmt.Errorf("workflow +review-context requires --number with --owner and --repo for read-only fetch")
}
fileLimit, err := parseIntArg(ctx.Arg("file-limit"), 100, "file-limit")
if err != nil {
return err
}
reviewLimit, err := parseIntArg(ctx.Arg("review-limit"), 100, "review-limit")
if err != nil {
return err
}
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit")
if err != nil {
return err
}
commitLimit, err := parseIntArg(ctx.Arg("commit-limit"), 100, "commit-limit")
if err != nil {
return err
}
ciLimit, err := parseIntArg(ctx.Arg("ci-limit"), 20, "ci-limit")
if err != nil {
return err
}
labelLimit, err := parseIntArg(ctx.Arg("label-limit"), 50, "label-limit")
if err != nil {
return err
}
context, err := FetchReviewContext(ctx, ReviewContextOptions{
Number: number,
FileLimit: fileLimit,
ReviewLimit: reviewLimit,
IssueLimit: issueLimit,
LabelLimit: labelLimit,
CommitLimit: commitLimit,
BuildLimit: ciLimit,
IncludeRepo: parseBoolDefault(ctx.Arg("include-repo"), true),
IncludePR: parseBoolDefault(ctx.Arg("include-pr"), true),
IncludeFiles: parseBoolDefault(ctx.Arg("include-files"), true),
IncludeReviews: parseBoolDefault(ctx.Arg("include-reviews"), true),
IncludeIssues: parseBoolDefault(ctx.Arg("include-issues"), true),
IncludeLabels: parseBoolDefault(ctx.Arg("include-labels"), true),
IncludeCommits: parseBoolDefault(ctx.Arg("include-commits"), false),
IncludeCI: parseBoolDefault(ctx.Arg("include-ci"), false),
})
if err != nil {
return err
}
format := ctx.Format
if strings.TrimSpace(cmdutil.Format) == "" {
format = "json"
}
rendered, err := RenderReviewContext(context, format)
if err != nil {
return err
}
_, err = fmt.Fprint(os.Stdout, rendered)
return err
}
func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (ReviewContext, error) {
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
if err != nil {
return ReviewContext{}, fmt.Errorf("workflow +review-context remote mode requires --owner and --repo or a Git remote: %w", err)
}
if opts.Number <= 0 {
return ReviewContext{}, fmt.Errorf("pull request number is required")
}
if opts.IssueLimit <= 0 {
opts.IssueLimit = 20
}
if opts.LabelLimit <= 0 {
opts.LabelLimit = 50
}
if opts.FileLimit <= 0 {
opts.FileLimit = 100
}
if opts.ReviewLimit <= 0 {
opts.ReviewLimit = 100
}
if opts.CommitLimit <= 0 {
opts.CommitLimit = 100
}
if opts.BuildLimit <= 0 {
opts.BuildLimit = 20
}
result := ReviewContext{
Repository: fmt.Sprintf("%s/%s", owner, repo),
PullRequest: opts.Number,
Source: "shortcut-backed-read-only-fetch",
Sections: []string{},
Notes: []ScoringNote{},
}
successes := 0
var prEvidence map[string]interface{}
if opts.IncludeRepo {
if info, err := fetchRepoInfo(ctx, owner, repo); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "repo_info", Note: fmt.Sprintf("repo +info equivalent failed: %v", err)})
} else {
result.RepositoryInfo = info
result.Sections = append(result.Sections, "repo_info")
successes++
}
}
if opts.IncludePR || opts.IncludeCommits || opts.IncludeCI {
if pr, err := fetchReviewContextPR(ctx, owner, repo, opts.Number); err != nil {
if opts.IncludePR {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_view", Note: fmt.Sprintf("pr +view equivalent failed: %v", err)})
}
} else {
prEvidence = pr
if opts.IncludePR {
result.PR = pr
result.Sections = append(result.Sections, "pr")
successes++
}
}
}
if opts.IncludeFiles {
if files, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/files", plainRepoPath(owner, repo), opts.Number), nil, opts.FileLimit); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_files", Note: fmt.Sprintf("pr +files equivalent failed: %v", err)})
} else {
result.Files = files
result.Sections = append(result.Sections, "files")
successes++
}
}
if opts.IncludeReviews {
if reviews, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/reviews", workflowRepoPath(owner, repo), opts.Number), nil, opts.ReviewLimit); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_reviews", Note: fmt.Sprintf("pr +reviews equivalent failed: %v", err)})
} else {
result.Reviews = reviews
result.Sections = append(result.Sections, "reviews")
successes++
}
}
if opts.IncludeCommits {
commitPath, err := reviewContextCommitPath(owner, repo, prEvidence)
if err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_commits", Note: err.Error()})
} else if commits, err := fetchReviewContextList(ctx, commitPath, nil, opts.CommitLimit); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_commits", Note: fmt.Sprintf("pr +commits equivalent failed: %v", err)})
} else {
result.Commits = commits
result.Sections = append(result.Sections, "commits")
successes++
}
}
if opts.IncludeCI {
if builds, err := fetchReviewContextList(ctx, plainRepoPath(owner, repo)+"/builds", nil, opts.BuildLimit); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "ci_builds", Note: fmt.Sprintf("ci +builds equivalent failed: %v", err)})
} else {
result.Builds = builds
result.CISummary = summarizeReviewCI(prEvidence, result.Commits, builds)
result.Sections = append(result.Sections, "ci_builds")
successes++
}
}
if opts.IncludeIssues {
query := url.Values{}
query.Set("category", "opened")
if issues, err := fetchReviewContextList(ctx, workflowRepoPath(owner, repo)+"/issues", query, opts.IssueLimit); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "open_issues", Note: fmt.Sprintf("issue +list equivalent failed: %v", err)})
} else {
result.OpenIssues = issues
result.Sections = append(result.Sections, "open_issues")
successes++
}
}
if opts.IncludeLabels {
if labels, err := fetchReviewContextList(ctx, workflowRepoPath(owner, repo)+"/issue_tags", nil, opts.LabelLimit); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "labels", Note: fmt.Sprintf("label +list equivalent failed: %v", err)})
} else {
result.Labels = labels
result.Sections = append(result.Sections, "labels")
successes++
}
}
result.Notes = uniqueScoringNotes(result.Notes)
if successes == 0 {
return ReviewContext{}, fmt.Errorf("fetch review context: all enabled sections failed")
}
return result, nil
}
func summarizeReviewCI(pr map[string]interface{}, commits, builds []map[string]interface{}) *ReviewCISummary {
summary := &ReviewCISummary{Total: len(builds), MatchMode: "unavailable"}
if pr == nil {
summary.MatchMode = "unavailable"
} else {
summary.HeadBranch = firstPRBranch(pr, "head_branch", "source_branch", "head")
summary.HeadSHA = reviewContextHeadSHA(pr, commits)
if summary.HeadSHA != "" {
summary.MatchMode = "sha"
} else if summary.HeadBranch != "" {
summary.MatchMode = "branch"
}
}
shaMatchedBuilds := make([]map[string]interface{}, 0, len(builds))
branchMatchedBuilds := make([]map[string]interface{}, 0, len(builds))
for _, build := range builds {
buildSHA := firstPRString(build, "head_commit_sha", "commit_sha", "commit_id", "sha", "after", "revision")
buildBranch := normalizeBuildBranch(firstPRString(build, "branch", "head_branch", "source_branch", "ref"))
if summary.HeadSHA != "" && commitPrefixMatches(buildSHA, summary.HeadSHA) {
shaMatchedBuilds = append(shaMatchedBuilds, build)
}
if summary.HeadBranch != "" && buildBranch == normalizeBuildBranch(summary.HeadBranch) && buildBranch != "" {
branchMatchedBuilds = append(branchMatchedBuilds, build)
}
}
matchedBuilds := shaMatchedBuilds
if len(matchedBuilds) > 0 {
summary.MatchMode = "sha"
} else if len(branchMatchedBuilds) > 0 {
matchedBuilds = branchMatchedBuilds
summary.MatchMode = "branch"
} else if len(builds) > 0 {
summary.MatchMode = "none"
}
summary.Matched = len(matchedBuilds)
summary.Unmatched = summary.Total - summary.Matched
for _, build := range matchedBuilds {
classifyReviewBuild(summary, build)
}
return summary
}
func reviewContextHeadSHA(pr map[string]interface{}, commits []map[string]interface{}) string {
if sha := firstPRString(pr, "head_commit_sha", "head_sha", "head_sha1", "last_commit_id"); sha != "" {
return sha
}
var headSHA string
var headTime time.Time
for _, commit := range commits {
sha := firstPRString(commit, "sha", "commit_sha", "id")
if sha == "" {
continue
}
commitTime := apiLatestTime(
firstPRTime(commit, "timestamp"),
firstPRTime(commit, "created_at", "committed_at", "date"),
)
if headSHA == "" || (!commitTime.IsZero() && (headTime.IsZero() || commitTime.After(headTime))) {
headSHA = sha
headTime = commitTime
}
}
return headSHA
}
func classifyReviewBuild(summary *ReviewCISummary, build map[string]interface{}) {
statusValues := make([]string, 0, 6)
for _, key := range []string{"status", "state", "build_status", "phase", "conclusion", "result"} {
if value := strings.ToLower(strings.TrimSpace(firstPRString(build, key))); value != "" {
statusValues = append(statusValues, value)
}
}
status := strings.Join(statusValues, " ")
switch {
case containsAny(status, []string{"failure", "failed", "error", "cancel", "red"}):
summary.Failed++
case containsAny(status, []string{"success", "succeeded", "passed", "pass", "green"}):
summary.Passed++
case containsAny(status, []string{"pending", "running", "queued", "waiting", "created", "started"}):
summary.Pending++
default:
summary.Unknown++
}
}
func normalizeBuildBranch(branch string) string {
return strings.TrimPrefix(strings.TrimSpace(branch), "refs/heads/")
}
func commitPrefixMatches(left, right string) bool {
left = strings.ToLower(strings.TrimSpace(left))
right = strings.ToLower(strings.TrimSpace(right))
if left == "" || right == "" {
return false
}
if left == right {
return true
}
return len(left) >= 7 && len(right) >= 7 && (strings.HasPrefix(left, right) || strings.HasPrefix(right, left))
}
func fetchReviewContextPR(ctx *common.RuntimeContext, owner, repo string, number int) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%d", plainRepoPath(owner, repo), number), nil)
if err != nil {
return nil, err
}
item := prAPIObject(env.Data)
if item == nil {
return nil, fmt.Errorf("PR response did not contain an object")
}
return item, nil
}
func reviewContextCommitPath(owner, repo string, pr map[string]interface{}) (string, error) {
if pr == nil {
return "", fmt.Errorf("pr +commits requires PR details to resolve the internal pull_request_id")
}
id := firstPRInt(pr, "id", "pull_request_id")
if id <= 0 {
return "", fmt.Errorf("pr +commits response is unavailable: PR details did not contain pull_request_id")
}
return fmt.Sprintf("%s/pulls/%d/commits", plainRepoPath(owner, repo), id), nil
}
func fetchReviewContextList(ctx *common.RuntimeContext, path string, query url.Values, limit int) ([]map[string]interface{}, error) {
if limit <= 0 {
limit = 100
}
q := cloneValues(query)
q.Set("page", "1")
q.Set("limit", fmt.Sprintf("%d", limit))
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return nil, err
}
if !isStructuredReviewContextList(env.Data) {
return nil, fmt.Errorf("response did not contain a structured list")
}
items := apiList(env.Data)
out := make([]map[string]interface{}, 0, len(items))
for _, raw := range items {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
out = append(out, item)
if len(out) >= limit {
break
}
}
return out, nil
}
func isStructuredReviewContextList(data interface{}) bool {
normalized, err := normalizeAPIData(data)
if err != nil {
return false
}
switch value := normalized.(type) {
case []interface{}:
return true
case map[string]interface{}:
for _, key := range []string{"issues", "pulls", "pull_requests", "reviews", "journals", "comments", "notes", "files", "commits", "releases", "builds", "issue_tags", "tags", "items", "records"} {
if _, ok := value[key]; ok {
return true
}
}
if nested, ok := value["data"]; ok {
return isStructuredReviewContextList(nested)
}
return looksLikeIssueOrRepoItem(value)
default:
return false
}
}
func RenderReviewContext(context ReviewContext, format string) (string, error) {
var rendered strings.Builder
switch normalizeFormat(format) {
case "json":
if err := writeJSON(&rendered, context); err != nil {
return "", err
}
case "markdown":
if err := writeReviewContextMarkdown(&rendered, context); err != nil {
return "", err
}
case "table":
if err := writeReviewContextTable(&rendered, context); err != nil {
return "", err
}
default:
return "", fmt.Errorf("unsupported workflow output format %q", format)
}
return rendered.String(), nil
}
func writeReviewContextMarkdown(w *strings.Builder, context ReviewContext) error {
_, _ = fmt.Fprintf(w, "# PR Review Context\n\n")
_, _ = fmt.Fprintf(w, "- Repository: `%s`\n", context.Repository)
_, _ = fmt.Fprintf(w, "- Pull request: `#%d`\n", context.PullRequest)
_, _ = fmt.Fprintf(w, "- Source: `%s`\n", context.Source)
_, _ = fmt.Fprintf(w, "- Sections: `%s`\n", strings.Join(context.Sections, ", "))
_, _ = fmt.Fprintf(w, "\n## Summary\n\n")
_, _ = fmt.Fprintf(w, "- Changed files: `%d`\n", len(context.Files))
_, _ = fmt.Fprintf(w, "- Reviews: `%d`\n", len(context.Reviews))
_, _ = fmt.Fprintf(w, "- Commits: `%d`\n", len(context.Commits))
_, _ = fmt.Fprintf(w, "- CI builds: `%d`\n", len(context.Builds))
if context.CISummary != nil {
_, _ = fmt.Fprintf(w, "- CI matched: `%d/%d` (`%s`), unmatched `%d`, passed `%d`, failed `%d`, pending `%d`, unknown `%d`\n",
context.CISummary.Matched, context.CISummary.Total, context.CISummary.MatchMode,
context.CISummary.Unmatched,
context.CISummary.Passed, context.CISummary.Failed, context.CISummary.Pending, context.CISummary.Unknown)
}
_, _ = fmt.Fprintf(w, "- Open issues included: `%d`\n", len(context.OpenIssues))
_, _ = fmt.Fprintf(w, "- Labels included: `%d`\n", len(context.Labels))
if len(context.Notes) > 0 {
_, _ = fmt.Fprintf(w, "\n## Notes\n\n")
for _, note := range context.Notes {
_, _ = fmt.Fprintf(w, "- `%s`: %s\n", note.Metric, note.Note)
}
}
return nil
}
func writeReviewContextTable(w *strings.Builder, context ReviewContext) error {
_, _ = fmt.Fprintf(w, "REPOSITORY\tPR\tSECTIONS\tFILES\tREVIEWS\tCOMMITS\tCI\tISSUES\tLABELS\tNOTES\n")
_, _ = fmt.Fprintf(w, "%s\t#%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n",
context.Repository,
context.PullRequest,
len(context.Sections),
len(context.Files),
len(context.Reviews),
len(context.Commits),
len(context.Builds),
len(context.OpenIssues),
len(context.Labels),
len(context.Notes),
)
return nil
}
func plainRepoPath(owner, repo string) string {
return fmt.Sprintf("/%s/%s", strings.TrimSpace(owner), strings.TrimSpace(repo))
}
func parseBoolDefault(value string, defaultValue bool) bool {
value = strings.TrimSpace(value)
if value == "" {
return defaultValue
}
return parseBoolArg(value)
}