468 lines
16 KiB
Go
468 lines
16 KiB
Go
package workflow
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
"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
|
|
|
|
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 {
|
|
if pr, err := fetchReviewContextPR(ctx, owner, repo, opts.Number); err != nil {
|
|
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_view", Note: fmt.Sprintf("pr +view equivalent failed: %v", err)})
|
|
} else {
|
|
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 {
|
|
if commits, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/commits", workflowRepoPath(owner, repo), opts.Number), 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(result.PR, 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{}, 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 = firstPRString(pr, "head_commit_sha", "head_sha", "head_sha1", "last_commit_id")
|
|
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 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 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
|
|
}
|
|
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 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)
|
|
}
|