gitlink-cli/shortcuts/workflow/review_context.go

298 lines
9.9 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
IssueLimit int
LabelLimit int
IncludeRepo bool
IncludePR bool
IncludeFiles bool
IncludeReviews bool
IncludeIssues bool
IncludeLabels 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"`
Notes []ScoringNote `json:"notes,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: "issue-limit", Usage: "Maximum open issues to include", Default: "20"},
{Name: "label-limit", Usage: "Maximum labels to include", Default: "50"},
{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"},
},
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")
}
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-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,
IssueLimit: issueLimit,
LabelLimit: labelLimit,
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),
})
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
}
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, 100); 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, 100); 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.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 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, "- 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\tISSUES\tLABELS\tNOTES\n")
_, _ = fmt.Fprintf(w, "%s\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.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)
}