415 lines
10 KiB
Go
415 lines
10 KiB
Go
package workflow
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
type PRFetchOptions struct {
|
|
Owner string
|
|
Repo string
|
|
Number int
|
|
IncludeFiles bool
|
|
IncludeCommits bool
|
|
MaxFiles int
|
|
MaxCommits int
|
|
}
|
|
|
|
func FetchPRSummaryInput(ctx *common.RuntimeContext, opts PRFetchOptions) (PRSummaryInput, []ScoringNote, error) {
|
|
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
|
|
if err != nil {
|
|
return PRSummaryInput{}, nil, err
|
|
}
|
|
if opts.Number <= 0 {
|
|
return PRSummaryInput{}, nil, fmt.Errorf("pull request number is required")
|
|
}
|
|
if opts.MaxFiles <= 0 {
|
|
opts.MaxFiles = 100
|
|
}
|
|
if opts.MaxCommits <= 0 {
|
|
opts.MaxCommits = 100
|
|
}
|
|
|
|
input, err := fetchPRBase(ctx, owner, repo, opts.Number)
|
|
if err != nil {
|
|
return PRSummaryInput{}, nil, err
|
|
}
|
|
input.Repository = fmt.Sprintf("%s/%s", owner, repo)
|
|
input.Number = opts.Number
|
|
input.Source = "remote-read-only-fetch"
|
|
|
|
notes := []ScoringNote{}
|
|
if opts.IncludeFiles {
|
|
files, err := fetchPRFiles(ctx, owner, repo, opts.Number, opts.MaxFiles)
|
|
if err != nil {
|
|
notes = append(notes, ScoringNote{Metric: "pr_files", Note: fmt.Sprintf("changed files probe failed: %v", err)})
|
|
} else {
|
|
input.ChangedFiles = files
|
|
fillPRLineTotals(&input)
|
|
}
|
|
}
|
|
if opts.IncludeCommits {
|
|
commits, err := fetchPRCommits(ctx, owner, repo, opts.Number, opts.MaxCommits)
|
|
if err != nil {
|
|
notes = append(notes, ScoringNote{Metric: "pr_commits", Note: fmt.Sprintf("commits probe failed: %v", err)})
|
|
} else {
|
|
input.Commits = commits
|
|
}
|
|
}
|
|
|
|
return input, notes, nil
|
|
}
|
|
|
|
func fetchPRBase(ctx *common.RuntimeContext, owner, repo string, number int) (PRSummaryInput, error) {
|
|
env, err := ctx.CallAPI("GET", prPath(owner, repo, number), nil)
|
|
if err != nil {
|
|
return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: %w", err)
|
|
}
|
|
item := prAPIObject(env.Data)
|
|
if item == nil {
|
|
return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: PR response did not contain an object")
|
|
}
|
|
input, ok := normalizePRSummaryItem(item)
|
|
if !ok {
|
|
return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: PR response missing title or number")
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func fetchPRFiles(ctx *common.RuntimeContext, owner, repo string, number, limit int) ([]PRChangedFile, error) {
|
|
query := url.Values{}
|
|
query.Set("page", "1")
|
|
query.Set("limit", fmt.Sprintf("%d", limit))
|
|
env, err := ctx.CallAPIWithQuery("GET", prPath(owner, repo, number)+"/files", query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rawItems := apiList(env.Data)
|
|
files := make([]PRChangedFile, 0, len(rawItems))
|
|
for _, raw := range rawItems {
|
|
file, ok := normalizePRChangedFile(raw)
|
|
if !ok {
|
|
continue
|
|
}
|
|
files = append(files, file)
|
|
if len(files) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func fetchPRCommits(ctx *common.RuntimeContext, owner, repo string, number, limit int) ([]PRCommit, error) {
|
|
query := url.Values{}
|
|
query.Set("page", "1")
|
|
query.Set("limit", fmt.Sprintf("%d", limit))
|
|
env, err := ctx.CallAPIWithQuery("GET", prPath(owner, repo, number)+"/commits", query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rawItems := apiList(env.Data)
|
|
commits := make([]PRCommit, 0, len(rawItems))
|
|
for _, raw := range rawItems {
|
|
commit, ok := normalizePRCommit(raw)
|
|
if !ok {
|
|
continue
|
|
}
|
|
commits = append(commits, commit)
|
|
if len(commits) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return commits, nil
|
|
}
|
|
|
|
func prPath(owner, repo string, number int) string {
|
|
return fmt.Sprintf("%s/pulls/%d", workflowRepoPath(owner, repo), number)
|
|
}
|
|
|
|
func prAPIObject(data interface{}) map[string]interface{} {
|
|
normalized, err := normalizeAPIData(data)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
switch value := normalized.(type) {
|
|
case map[string]interface{}:
|
|
for _, key := range []string{"pull_request", "data", "pr"} {
|
|
if raw, ok := value[key]; ok {
|
|
if item := prAPIObject(raw); item != nil {
|
|
return item
|
|
}
|
|
}
|
|
}
|
|
return value
|
|
case []interface{}:
|
|
if len(value) == 1 {
|
|
if item, ok := value[0].(map[string]interface{}); ok {
|
|
return item
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) {
|
|
number := firstPRInt(item, "number", "index", "iid", "pull_request_number")
|
|
title := firstPRString(item, "title", "subject")
|
|
if number == 0 && strings.TrimSpace(title) == "" {
|
|
return PRSummaryInput{}, false
|
|
}
|
|
body := firstPRString(item, "body", "description", "content")
|
|
state := firstPRString(item, "state", "status")
|
|
author := firstPRAuthor(item)
|
|
issueID := firstPRIssueID(item)
|
|
base := firstPRBranch(item, "base_branch", "target_branch", "base")
|
|
head := firstPRBranch(item, "head_branch", "source_branch", "head")
|
|
createdAt := firstPRTime(item, "created_at", "createdAt")
|
|
updatedAt := apiLatestTime(
|
|
firstPRTime(item, "updated_at", "updatedAt"),
|
|
firstPRTime(item, "last_updated_at", "lastUpdatedAt"),
|
|
firstPRTime(item, "last_activity_at", "lastActivityAt"),
|
|
)
|
|
reviewState := firstPRString(item, "review_state", "review_status", "reviewer_state", "review_decision")
|
|
reviewers := firstPRStringList(item, "reviewers", "reviewer", "requested_reviewers", "reviewer_logins")
|
|
additions := firstPRInt(item, "additions", "additions_count")
|
|
deletions := firstPRInt(item, "deletions", "deletions_count")
|
|
|
|
return PRSummaryInput{
|
|
Number: number,
|
|
IssueID: issueID,
|
|
Title: title,
|
|
Author: author,
|
|
State: state,
|
|
BaseBranch: base,
|
|
HeadBranch: head,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
ReviewState: reviewState,
|
|
Reviewers: reviewers,
|
|
Body: body,
|
|
Additions: additions,
|
|
Deletions: deletions,
|
|
}, true
|
|
}
|
|
|
|
func normalizePRChangedFile(raw interface{}) (PRChangedFile, bool) {
|
|
item, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return PRChangedFile{}, false
|
|
}
|
|
filename := firstPRString(item, "filename", "file", "path", "new_path")
|
|
if strings.TrimSpace(filename) == "" {
|
|
return PRChangedFile{}, false
|
|
}
|
|
additions := firstPRInt(item, "additions", "additions_count")
|
|
deletions := firstPRInt(item, "deletions", "deletions_count")
|
|
changes := firstPRInt(item, "changes", "total_changes")
|
|
if changes == 0 {
|
|
changes = additions + deletions
|
|
}
|
|
return PRChangedFile{
|
|
Filename: filename,
|
|
Status: firstPRString(item, "status", "state"),
|
|
Additions: additions,
|
|
Deletions: deletions,
|
|
Changes: changes,
|
|
Patch: firstPRString(item, "patch", "diff"),
|
|
}, true
|
|
}
|
|
|
|
func normalizePRCommit(raw interface{}) (PRCommit, bool) {
|
|
item, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return PRCommit{}, false
|
|
}
|
|
sha := firstPRString(item, "sha", "id")
|
|
message := firstPRString(item, "message", "title")
|
|
if strings.TrimSpace(sha) == "" && strings.TrimSpace(message) == "" {
|
|
return PRCommit{}, false
|
|
}
|
|
return PRCommit{
|
|
SHA: sha,
|
|
Message: firstLine(message),
|
|
Author: firstPRCommitAuthor(item),
|
|
Date: firstPRTime(item, "date", "committed_at", "created_at"),
|
|
}, true
|
|
}
|
|
|
|
func fillPRLineTotals(input *PRSummaryInput) {
|
|
if input == nil || len(input.ChangedFiles) == 0 {
|
|
return
|
|
}
|
|
additions := 0
|
|
deletions := 0
|
|
for _, file := range input.ChangedFiles {
|
|
additions += file.Additions
|
|
deletions += file.Deletions
|
|
}
|
|
if input.Additions == 0 {
|
|
input.Additions = additions
|
|
}
|
|
if input.Deletions == 0 {
|
|
input.Deletions = deletions
|
|
}
|
|
}
|
|
|
|
func firstPRString(item map[string]interface{}, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := item[key]; ok {
|
|
if s := apiString(value); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstPRStringList(item map[string]interface{}, keys ...string) []string {
|
|
for _, key := range keys {
|
|
value, ok := item[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
values := normalizePRStringList(value)
|
|
if len(values) > 0 {
|
|
return values
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizePRStringList(value interface{}) []string {
|
|
values := []string{}
|
|
appendValue := func(raw interface{}) {
|
|
if item, ok := raw.(map[string]interface{}); ok {
|
|
for _, key := range []string{"login", "username", "name", "handle"} {
|
|
if name := strings.TrimSpace(apiString(item[key])); name != "" {
|
|
values = append(values, name)
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
for _, part := range strings.FieldsFunc(apiString(raw), func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
|
|
if name := strings.TrimSpace(part); name != "" {
|
|
values = append(values, name)
|
|
}
|
|
}
|
|
}
|
|
switch typed := value.(type) {
|
|
case []interface{}:
|
|
for _, item := range typed {
|
|
appendValue(item)
|
|
}
|
|
case []string:
|
|
for _, item := range typed {
|
|
appendValue(item)
|
|
}
|
|
default:
|
|
appendValue(value)
|
|
}
|
|
return uniqueStrings(values)
|
|
}
|
|
|
|
func firstPRInt(item map[string]interface{}, keys ...string) int {
|
|
for _, key := range keys {
|
|
if value, ok := item[key]; ok {
|
|
if n := apiInt(value); n != 0 {
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstPRTime(item map[string]interface{}, keys ...string) time.Time {
|
|
for _, key := range keys {
|
|
if value, ok := item[key]; ok {
|
|
if t := apiTime(value); !t.IsZero() {
|
|
return t
|
|
}
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func firstPRAuthor(item map[string]interface{}) string {
|
|
for _, key := range []string{"author", "user", "creator"} {
|
|
if value, ok := item[key]; ok {
|
|
if s := apiAuthor(value); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstPRCommitAuthor(item map[string]interface{}) string {
|
|
for _, key := range []string{"author", "committer", "user", "creator"} {
|
|
if value, ok := item[key]; ok {
|
|
if s := apiAuthor(value); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstPRIssueID(item map[string]interface{}) int {
|
|
for _, key := range []string{"issue_id", "issueId"} {
|
|
if value, ok := item[key]; ok {
|
|
if id := apiInt(value); id != 0 {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
for _, key := range []string{"issue", "issue_info"} {
|
|
if raw, ok := item[key].(map[string]interface{}); ok {
|
|
for _, field := range []string{"id", "issue_id"} {
|
|
if id := apiInt(raw[field]); id != 0 {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstPRBranch(item map[string]interface{}, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := item[key]; ok {
|
|
if s := prBranchString(value); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func prBranchString(value interface{}) string {
|
|
switch typed := value.(type) {
|
|
case map[string]interface{}:
|
|
for _, key := range []string{"ref", "name", "branch", "title"} {
|
|
if s := apiString(typed[key]); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
default:
|
|
return apiString(value)
|
|
}
|
|
}
|
|
|
|
func firstLine(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
lines := strings.Split(value, "\n")
|
|
return strings.TrimSpace(lines[0])
|
|
}
|