Merge PR #194: fix(pr): 补齐 pr +view 的合并与关闭时间字段
# Conflicts: # shortcuts/pr/pr.go # shortcuts/pr/pr_test.go
This commit is contained in:
commit
32494e25c3
|
|
@ -401,6 +401,7 @@ gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: New feature" -
|
|||
|
||||
# View a PR
|
||||
gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
||||
# For merged or closed PRs, JSON output also normalizes `created_at`, `merged_at`, `closed_at`, and `closed_on` when GitLink provides or journals can infer them.
|
||||
|
||||
# Merge a PR
|
||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
||||
|
|
|
|||
|
|
@ -544,6 +544,7 @@ gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 新功能" --h
|
|||
|
||||
# 查看 PR
|
||||
gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
||||
# 对于已合并或已关闭的 PR,JSON 输出会尽量补齐 `created_at`、`merged_at`、`closed_at` 和 `closed_on`。
|
||||
|
||||
# 在本地检出 PR 分支(对标 `gh pr checkout`;需在 git 克隆目录内执行)
|
||||
gitlink-cli pr +checkout --owner Gitlink --repo forgeplus -i 42
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
# PR View Timestamp Normalization
|
||||
|
||||
## Summary
|
||||
|
||||
`gitlink-cli pr +view` now normalizes pull request lifecycle timestamps so merged and closed pull requests expose stable top-level time fields in JSON output.
|
||||
|
||||
## Command
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `gitlink-cli pr +view` | Return PR detail and normalize `created_at`, `merged_at`, `closed_at`, and `closed_on` when GitLink provides them directly or journals can infer them. |
|
||||
|
||||
## Behavior
|
||||
|
||||
- Promote `created_at` and `merged_at` from nested response objects to the top-level payload.
|
||||
- Backfill `closed_at` and `closed_on` for merged pull requests when GitLink omits an explicit close timestamp.
|
||||
- Read issue journals only when a merged or closed pull request is still missing lifecycle timestamps.
|
||||
- Recognize merge and close journal operations after stripping HTML tags and whitespace.
|
||||
- Support both `pull_request_status` and `pull_request_staus` status shapes returned by GitLink APIs.
|
||||
|
||||
## Tests
|
||||
|
||||
- `go test ./shortcuts/pr/...`
|
||||
- `go build ./...`
|
||||
- `go test ./...`
|
||||
- `go run . pr +view --owner Gitlink --repo gitlink-cli -i 15 --format json`
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
package pr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
|
|
@ -12,6 +12,8 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var pullRequestJournalHTMLTagPattern = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
|
@ -47,7 +49,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
{Name: "assignee-id", Usage: tr.T("flag.pr.assignee_id")},
|
||||
{Name: "sort-by", Usage: tr.T("flag.sort_by")},
|
||||
{Name: "sort-direction", Usage: tr.T("flag.sort_direction")},
|
||||
{Name: "login", Usage: "Filter pull requests by issue author login"},
|
||||
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||
},
|
||||
|
|
@ -89,9 +90,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if login := ctx.Arg("login"); login != "" {
|
||||
filterPullsByLogin(env, login)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -144,7 +142,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := enrichPullRequestClosedAt(ctx, env); err != nil {
|
||||
if err := enrichPullRequestTimestamps(ctx, env); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
|
|
@ -404,235 +402,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "review-comments",
|
||||
Description: "List inline review comments on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
|
||||
{Name: "review-id", Usage: "Review ID"},
|
||||
{Name: "need-respond", Usage: "Filter by whether comments still need a response: true or false"},
|
||||
{Name: "state", Short: "s", Usage: "Comment state: opened, resolved, or disabled"},
|
||||
{Name: "parent-id", Usage: "Parent comment ID"},
|
||||
{Name: "path", Short: "f", Usage: "Filter by file path"},
|
||||
{Name: "is-full", Usage: "Whether to include reply comments: true or false"},
|
||||
{Name: "sort-by", Usage: "Sort field: created_on or updated_on"},
|
||||
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
if reviewID := ctx.Arg("review-id"); reviewID != "" {
|
||||
q.Set("review_id", reviewID)
|
||||
}
|
||||
if needRespond := ctx.Arg("need-respond"); needRespond != "" {
|
||||
q.Set("need_respond", needRespond)
|
||||
}
|
||||
if state := ctx.Arg("state"); state != "" {
|
||||
normalizedState, err := normalizePRReviewCommentState(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q.Set("state", normalizedState)
|
||||
}
|
||||
if parentID := ctx.Arg("parent-id"); parentID != "" {
|
||||
q.Set("parent_id", parentID)
|
||||
}
|
||||
if path := ctx.Arg("path"); path != "" {
|
||||
q.Set("path", path)
|
||||
}
|
||||
if isFull := ctx.Arg("is-full"); isFull != "" {
|
||||
q.Set("is_full", isFull)
|
||||
}
|
||||
if sortBy := ctx.Arg("sort-by"); sortBy != "" {
|
||||
q.Set("sort_by", sortBy)
|
||||
}
|
||||
if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" {
|
||||
q.Set("sort_direction", sortDirection)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", prV1Path(ctx, id)+"/journals", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "review-comment",
|
||||
Description: "Create an inline review comment on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
|
||||
{Name: "review-id", Usage: "Review ID", Required: true},
|
||||
{Name: "path", Short: "f", Usage: "File path to comment on", Required: true},
|
||||
{Name: "line-code", Usage: "Line code returned by the diff API", Required: true},
|
||||
{Name: "note", Short: "n", Usage: "Comment body", Required: true},
|
||||
{Name: "type", Short: "t", Usage: "Comment type: comment or problem", Default: "comment"},
|
||||
{Name: "commit", Short: "m", Usage: "Commit SHA for the comment"},
|
||||
{Name: "parent-id", Usage: "Parent comment ID for replies"},
|
||||
{Name: "diff-file", Usage: "Load diff JSON from a file instead of fetching PR files automatically"},
|
||||
{Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reviewID, err := ctx.RequireArg("review-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lineCode, err := ctx.RequireArg("line-code")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
note, err := ctx.RequireArg("note")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentType, err := normalizePRReviewCommentType(ctx.Arg("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
diff, diffSource, err := loadPRReviewCommentDiff(ctx, id, path, ctx.Arg("diff-file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"type": commentType,
|
||||
"note": note,
|
||||
"review_id": reviewID,
|
||||
"line_code": lineCode,
|
||||
"path": path,
|
||||
"diff": diff,
|
||||
}
|
||||
if commit := ctx.Arg("commit"); commit != "" {
|
||||
payload["commit_id"] = commit
|
||||
}
|
||||
if parentID := ctx.Arg("parent-id"); parentID != "" {
|
||||
payload["parent_id"] = parentID
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"pull_request": id,
|
||||
"dry_run": true,
|
||||
"action": "create_review_comment",
|
||||
"diff_source": diffSource,
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/journals", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update-review-comment",
|
||||
Description: "Update an inline review comment on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
|
||||
{Name: "comment-id", Usage: "Review comment ID", Required: true},
|
||||
{Name: "note", Short: "n", Usage: "Updated comment body"},
|
||||
{Name: "state", Short: "s", Usage: "Comment state: opened, resolved, or disabled"},
|
||||
{Name: "commit", Short: "m", Usage: "Commit SHA to attach to the update"},
|
||||
{Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if note := ctx.Arg("note"); note != "" {
|
||||
payload["note"] = note
|
||||
}
|
||||
if state := ctx.Arg("state"); state != "" {
|
||||
normalizedState, err := normalizePRReviewCommentState(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload["state"] = normalizedState
|
||||
}
|
||||
if commit := ctx.Arg("commit"); commit != "" {
|
||||
payload["commit_id"] = commit
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return fmt.Errorf("at least one of --note, --state, or --commit is required")
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"pull_request": id,
|
||||
"dry_run": true,
|
||||
"action": "update_review_comment",
|
||||
"comment_id": commentID,
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", prV1Path(ctx, id)+"/journals/"+commentID, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete-review-comment",
|
||||
Description: "Delete an inline review comment from a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
|
||||
{Name: "comment-id", Usage: "Review comment ID", Required: true},
|
||||
{Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"pull_request": id,
|
||||
"dry_run": true,
|
||||
"action": "delete_review_comment",
|
||||
"comment_id": commentID,
|
||||
})
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", prV1Path(ctx, id)+"/journals/"+commentID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "comment",
|
||||
Description: tr.T("cmd.pr.comment.short"),
|
||||
|
|
@ -689,253 +458,6 @@ func validatePRReviewStatus(status string) error {
|
|||
}
|
||||
}
|
||||
|
||||
func normalizePRReviewCommentType(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "comment":
|
||||
return "comment", nil
|
||||
case "problem":
|
||||
return "problem", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --type value %q: use comment or problem", value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePRReviewCommentState(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "opened", "resolved", "disabled":
|
||||
return strings.ToLower(strings.TrimSpace(value)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --state value %q: use opened, resolved, or disabled", value)
|
||||
}
|
||||
}
|
||||
|
||||
func loadPRReviewCommentDiff(ctx *common.RuntimeContext, prID string, path string, diffFile string) (map[string]interface{}, string, error) {
|
||||
if diffFile != "" {
|
||||
raw, err := os.ReadFile(diffFile)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read --diff-file %q: %w", diffFile, err)
|
||||
}
|
||||
var payload interface{}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, "", fmt.Errorf("parse --diff-file %q: %w", diffFile, err)
|
||||
}
|
||||
diff, err := extractPRReviewCommentDiff(payload, path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return diff, diffFile, nil
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), prID), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
diff, err := extractPRReviewCommentDiff(env.Data, path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return diff, "pr_files_api", nil
|
||||
}
|
||||
|
||||
func extractPRReviewCommentDiff(data interface{}, path string) (map[string]interface{}, error) {
|
||||
diff, ok := findPRReviewCommentDiff(data, path)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no diff found for path %q; use --diff-file to provide the exact diff JSON", path)
|
||||
}
|
||||
return normalizePRReviewCommentDiff(diff, path), nil
|
||||
}
|
||||
|
||||
func findPRReviewCommentDiff(data interface{}, path string) (map[string]interface{}, bool) {
|
||||
switch v := data.(type) {
|
||||
case *output.Envelope:
|
||||
return findPRReviewCommentDiff(v.Data, path)
|
||||
case map[string]interface{}:
|
||||
if nested, ok := v["data"]; ok {
|
||||
if diff, found := findPRReviewCommentDiff(nested, path); found {
|
||||
return diff, true
|
||||
}
|
||||
}
|
||||
if diff, ok := v["diff"].(map[string]interface{}); ok {
|
||||
if files, ok := diff["files"]; ok {
|
||||
if found, ok := findPRReviewCommentDiff(files, path); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if files, ok := v["files"]; ok {
|
||||
if found, ok := findPRReviewCommentDiff(files, path); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
if looksLikePRReviewCommentDiff(v) && matchesPRReviewCommentPath(v, path) {
|
||||
return v, true
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
diff, ok := findPRReviewCommentDiff(item, path)
|
||||
if ok {
|
||||
return diff, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func looksLikePRReviewCommentDiff(diff map[string]interface{}) bool {
|
||||
_, hasName := diff["name"]
|
||||
_, hasSections := diff["sections"]
|
||||
_, hasAddition := diff["addition"]
|
||||
return (hasName || hasAddition) && hasSections
|
||||
}
|
||||
|
||||
func matchesPRReviewCommentPath(diff map[string]interface{}, path string) bool {
|
||||
for _, key := range []string{"name", "path", "filename", "fileName", "old_name", "oldname"} {
|
||||
if stringField(diff, key) == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizePRReviewCommentDiff(diff map[string]interface{}, path string) map[string]interface{} {
|
||||
normalized := cloneMap(diff)
|
||||
normalized["name"] = firstStringField(diff, "name", "path", "filename", "fileName")
|
||||
normalized["oldname"] = firstStringField(diff, "oldname", "old_name")
|
||||
if normalized["oldname"] == "" {
|
||||
normalized["oldname"] = normalized["name"]
|
||||
}
|
||||
copyBoolAlias(normalized, diff, "is_created", "isCreated", "is_created")
|
||||
copyBoolAlias(normalized, diff, "is_deleted", "isDeleted", "is_deleted")
|
||||
copyBoolAlias(normalized, diff, "is_bin", "isBin", "is_bin")
|
||||
copyBoolAlias(normalized, diff, "is_lfs_file", "isLFSFile", "is_lfs_file")
|
||||
copyBoolAlias(normalized, diff, "is_renamed", "isRenamed", "is_renamed")
|
||||
copyBoolAlias(normalized, diff, "is_ambiguous", "isAmbiguous", "is_ambiguous")
|
||||
copyBoolAlias(normalized, diff, "is_submodule", "isSubmodule", "is_submodule")
|
||||
if path != "" {
|
||||
normalized["path"] = path
|
||||
}
|
||||
if sections, ok := diff["sections"].([]interface{}); ok {
|
||||
normalized["sections"] = normalizePRReviewCommentSections(sections, normalized["name"])
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizePRReviewCommentSections(sections []interface{}, fallbackPath interface{}) []interface{} {
|
||||
normalized := make([]interface{}, 0, len(sections))
|
||||
for _, rawSection := range sections {
|
||||
section, ok := rawSection.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
next := cloneMap(section)
|
||||
next["file_name"] = firstStringField(section, "file_name", "fileName")
|
||||
if next["file_name"] == "" {
|
||||
next["file_name"] = fallbackPath
|
||||
}
|
||||
if lines, ok := section["lines"].([]interface{}); ok {
|
||||
next["lines"] = normalizePRReviewCommentLines(lines, next["file_name"])
|
||||
}
|
||||
normalized = append(normalized, next)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizePRReviewCommentLines(lines []interface{}, fallbackPath interface{}) []interface{} {
|
||||
normalized := make([]interface{}, 0, len(lines))
|
||||
for _, rawLine := range lines {
|
||||
line, ok := rawLine.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
next := cloneMap(line)
|
||||
if left, ok := firstNumberField(line, "left_index", "leftIdx"); ok {
|
||||
next["left_index"] = left
|
||||
}
|
||||
if right, ok := firstNumberField(line, "right_index", "rightIdx"); ok {
|
||||
next["right_index"] = right
|
||||
}
|
||||
if _, ok := next["match"]; !ok {
|
||||
next["match"] = inferPRReviewCommentLineMatch(line)
|
||||
}
|
||||
if sectionInfo, ok := line["sectionInfo"].(map[string]interface{}); ok {
|
||||
next["section_path"] = firstStringField(sectionInfo, "section_path", "path")
|
||||
if next["section_path"] == "" {
|
||||
next["section_path"] = fallbackPath
|
||||
}
|
||||
if v, ok := firstNumberField(sectionInfo, "section_last_left_index", "lastLeftIdx"); ok {
|
||||
next["section_last_left_index"] = v
|
||||
}
|
||||
if v, ok := firstNumberField(sectionInfo, "section_last_right_index", "lastRightIdx"); ok {
|
||||
next["section_last_right_index"] = v
|
||||
}
|
||||
if v, ok := firstNumberField(sectionInfo, "section_left_index", "leftIdx"); ok {
|
||||
next["section_left_index"] = v
|
||||
}
|
||||
if v, ok := firstNumberField(sectionInfo, "section_right_index", "rightIdx"); ok {
|
||||
next["section_right_index"] = v
|
||||
}
|
||||
if v, ok := firstNumberField(sectionInfo, "section_left_hunk_size", "leftHunkSize"); ok {
|
||||
next["section_left_hunk_size"] = v
|
||||
}
|
||||
if v, ok := firstNumberField(sectionInfo, "section_right_hunk_size", "rightHunkSize"); ok {
|
||||
next["section_right_hunk_size"] = v
|
||||
}
|
||||
}
|
||||
normalized = append(normalized, next)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func inferPRReviewCommentLineMatch(line map[string]interface{}) float64 {
|
||||
if match, ok := numberField(line, "match"); ok {
|
||||
return match
|
||||
}
|
||||
lineType, _ := numberField(line, "type")
|
||||
switch int(lineType) {
|
||||
case 2:
|
||||
return 1
|
||||
case 3:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMap(src map[string]interface{}) map[string]interface{} {
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func copyBoolAlias(dst map[string]interface{}, src map[string]interface{}, dstKey string, candidates ...string) {
|
||||
for _, key := range candidates {
|
||||
if v, ok := src[key].(bool); ok {
|
||||
dst[dstKey] = v
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstStringField(m map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if v := stringField(m, key); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNumberField(m map[string]interface{}, keys ...string) (float64, bool) {
|
||||
for _, key := range keys {
|
||||
if v, ok := numberField(m, key); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func extractIssueID(env *output.Envelope) (int64, error) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
|
|
@ -952,79 +474,187 @@ func extractIssueID(env *output.Envelope) (int64, error) {
|
|||
return int64(idFloat), nil
|
||||
}
|
||||
|
||||
func enrichPullRequestClosedAt(ctx *common.RuntimeContext, env *output.Envelope) error {
|
||||
func enrichPullRequestTimestamps(ctx *common.RuntimeContext, env *output.Envelope) error {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
pr, ok := data["pull_request"].(map[string]interface{})
|
||||
if !ok || !isClosedPullRequest(pr) || stringField(pr, "closed_at") != "" {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
issue, ok := data["issue"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
if createdAt := firstNonEmptyString(
|
||||
stringField(data, "created_at"),
|
||||
stringField(pr, "created_at"),
|
||||
stringField(issue, "created_at"),
|
||||
); createdAt != "" {
|
||||
data["created_at"] = createdAt
|
||||
}
|
||||
issueID, ok := numberField(issue, "id")
|
||||
if !ok {
|
||||
return nil
|
||||
|
||||
mergedAt := firstNonEmptyString(
|
||||
stringField(data, "merged_at"),
|
||||
stringField(pr, "merged_at"),
|
||||
stringField(issue, "merged_at"),
|
||||
)
|
||||
closedAt := firstNonEmptyString(
|
||||
stringField(data, "closed_at"),
|
||||
stringField(data, "closed_on"),
|
||||
stringField(pr, "closed_at"),
|
||||
stringField(issue, "closed_on"),
|
||||
)
|
||||
|
||||
if shouldFetchPullRequestTimestamps(pr, mergedAt, closedAt) {
|
||||
issueID, ok := numberField(issue, "id")
|
||||
if ok {
|
||||
journalsEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, int64(issueID)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
journalMergedAt, journalClosedAt := extractPullRequestJournalTimes(journalsEnv)
|
||||
mergedAt = firstNonEmptyString(mergedAt, journalMergedAt)
|
||||
closedAt = firstNonEmptyString(closedAt, journalClosedAt)
|
||||
}
|
||||
}
|
||||
journalsEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, int64(issueID)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
if closedAt == "" && isMergedPullRequest(pr) {
|
||||
closedAt = mergedAt
|
||||
}
|
||||
closedAt := extractPullRequestClosedAt(journalsEnv)
|
||||
if closedAt == "" {
|
||||
return nil
|
||||
|
||||
if mergedAt != "" {
|
||||
pr["merged_at"] = mergedAt
|
||||
data["merged_at"] = mergedAt
|
||||
}
|
||||
if closedAt != "" {
|
||||
pr["closed_at"] = closedAt
|
||||
data["closed_at"] = closedAt
|
||||
data["closed_on"] = closedAt
|
||||
if issue != nil {
|
||||
issue["closed_on"] = closedAt
|
||||
}
|
||||
}
|
||||
pr["closed_at"] = closedAt
|
||||
data["closed_at"] = closedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func isClosedPullRequest(pr map[string]interface{}) bool {
|
||||
if isMergedPullRequest(pr) {
|
||||
return true
|
||||
}
|
||||
if stringField(pr, "pull_request_staus") == "closed" || stringField(pr, "state") == "closed" {
|
||||
return true
|
||||
}
|
||||
status, ok := numberField(pr, "status")
|
||||
status, ok := numberField(pr, "pull_request_status")
|
||||
if ok {
|
||||
return int(status) == 2
|
||||
}
|
||||
status, ok = numberField(pr, "status")
|
||||
return ok && int(status) == 2
|
||||
}
|
||||
|
||||
func extractPullRequestClosedAt(env *output.Envelope) string {
|
||||
func isMergedPullRequest(pr map[string]interface{}) bool {
|
||||
if merged, ok := pr["merged"].(bool); ok && merged {
|
||||
return true
|
||||
}
|
||||
if stringField(pr, "pull_request_staus") == "merged" || stringField(pr, "state") == "merged" {
|
||||
return true
|
||||
}
|
||||
status, ok := numberField(pr, "pull_request_status")
|
||||
if ok {
|
||||
return int(status) == 1
|
||||
}
|
||||
status, ok = numberField(pr, "status")
|
||||
return ok && int(status) == 1
|
||||
}
|
||||
|
||||
func shouldFetchPullRequestTimestamps(pr map[string]interface{}, mergedAt, closedAt string) bool {
|
||||
if !isClosedPullRequest(pr) {
|
||||
return false
|
||||
}
|
||||
if isMergedPullRequest(pr) {
|
||||
return mergedAt == "" || closedAt == ""
|
||||
}
|
||||
return closedAt == ""
|
||||
}
|
||||
|
||||
func extractPullRequestJournalTimes(env *output.Envelope) (string, string) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
rawJournals, ok := data["journals"].([]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
var mergedAt string
|
||||
var closedAt string
|
||||
for i := len(rawJournals) - 1; i >= 0; i-- {
|
||||
journal, ok := rawJournals[i].(map[string]interface{})
|
||||
if !ok || stringField(journal, "operate_category") != "status" {
|
||||
continue
|
||||
}
|
||||
content := stringField(journal, "operate_content")
|
||||
if !isPullRequestCloseOperation(content) {
|
||||
timestamp := firstNonEmptyString(
|
||||
stringField(journal, "updated_at"),
|
||||
stringField(journal, "created_at"),
|
||||
)
|
||||
if timestamp == "" {
|
||||
continue
|
||||
}
|
||||
if updatedAt := stringField(journal, "updated_at"); updatedAt != "" {
|
||||
return updatedAt
|
||||
if mergedAt == "" && isPullRequestMergeOperation(content) {
|
||||
mergedAt = timestamp
|
||||
}
|
||||
if createdAt := stringField(journal, "created_at"); createdAt != "" {
|
||||
return createdAt
|
||||
if closedAt == "" && isPullRequestCloseOperation(content) {
|
||||
closedAt = timestamp
|
||||
}
|
||||
if mergedAt != "" && closedAt != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return mergedAt, closedAt
|
||||
}
|
||||
|
||||
func isPullRequestMergeOperation(content string) bool {
|
||||
content = normalizePullRequestJournalContent(content)
|
||||
return containsPullRequestMarker(content) &&
|
||||
(strings.Contains(content, "\u5408\u5e76\u4e86") ||
|
||||
strings.Contains(content, "\u5df2\u5408\u5e76") ||
|
||||
strings.Contains(content, "merged"))
|
||||
}
|
||||
|
||||
func isPullRequestCloseOperation(content string) bool {
|
||||
content = normalizePullRequestJournalContent(content)
|
||||
return containsPullRequestMarker(content) &&
|
||||
(strings.Contains(content, "\u62d2\u7edd") ||
|
||||
strings.Contains(content, "rejected") ||
|
||||
strings.Contains(content, "refused") ||
|
||||
strings.Contains(content, "\u5173\u95ed") ||
|
||||
strings.Contains(content, "closed"))
|
||||
}
|
||||
|
||||
func normalizePullRequestJournalContent(content string) string {
|
||||
content = html.UnescapeString(content)
|
||||
content = pullRequestJournalHTMLTagPattern.ReplaceAllString(content, "")
|
||||
content = strings.ToLower(strings.TrimSpace(content))
|
||||
return strings.Join(strings.Fields(content), "")
|
||||
}
|
||||
|
||||
func containsPullRequestMarker(content string) bool {
|
||||
return strings.Contains(content, "\u5408\u5e76\u8bf7\u6c42") || strings.Contains(content, "pullrequest")
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isPullRequestCloseOperation(content string) bool {
|
||||
content = strings.ToLower(content)
|
||||
return strings.Contains(content, "合并请求") &&
|
||||
(strings.Contains(content, "拒绝") || strings.Contains(content, "关闭") || strings.Contains(content, "closed"))
|
||||
}
|
||||
|
||||
func stringField(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
|
@ -1041,49 +671,3 @@ func numberField(m map[string]interface{}, key string) (float64, bool) {
|
|||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// filterPullsByLogin filters the pulls list in-place, keeping only items
|
||||
// whose issue.author.login matches the given login (case-insensitive).
|
||||
func filterPullsByLogin(env *output.Envelope, login string) {
|
||||
if env == nil || env.Data == nil {
|
||||
return
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rawPulls, ok := data["pulls"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pulls, ok := rawPulls.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
loginLower := strings.ToLower(login)
|
||||
filtered := make([]interface{}, 0, len(pulls))
|
||||
for _, item := range pulls {
|
||||
pull, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
issue, ok := pull["issue"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
author, ok := issue["author"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
authorLogin, _ := author["login"].(string)
|
||||
if strings.ToLower(authorLogin) == loginLower {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
data["pulls"] = filtered
|
||||
// Update total_count in data to reflect filtered count
|
||||
data["total_count"] = float64(len(filtered))
|
||||
if env.Meta != nil {
|
||||
env.Meta.TotalCount = len(filtered)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
|
|
@ -94,269 +92,6 @@ func TestPRCommentFailsWhenIssueFieldMissing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPRReviewCommentsListWithFilters(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/owner/repo/pulls/42/journals.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
query := r.URL.Query()
|
||||
assertEqual(t, query.Get("review_id"), "12")
|
||||
assertEqual(t, query.Get("need_respond"), "true")
|
||||
assertEqual(t, query.Get("state"), "resolved")
|
||||
assertEqual(t, query.Get("parent_id"), "3")
|
||||
assertEqual(t, query.Get("path"), "cmd/api/api.go")
|
||||
assertEqual(t, query.Get("is_full"), "true")
|
||||
assertEqual(t, query.Get("sort_by"), "updated_on")
|
||||
assertEqual(t, query.Get("sort_direction"), "desc")
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review-comments", map[string]string{
|
||||
"id": "42",
|
||||
"review-id": "12",
|
||||
"need-respond": "true",
|
||||
"state": "resolved",
|
||||
"parent-id": "3",
|
||||
"path": "cmd/api/api.go",
|
||||
"is-full": "true",
|
||||
"sort-by": "updated_on",
|
||||
"sort-direction": "desc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review-comments failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewCommentCreateAutoDiff(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/42/files.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "cmd/api/api.go",
|
||||
"old_name": "cmd/api/api.go",
|
||||
"addition": float64(1),
|
||||
"deletion": float64(0),
|
||||
"type": float64(2),
|
||||
"isCreated": false,
|
||||
"isDeleted": false,
|
||||
"isBin": false,
|
||||
"isLFSFile": false,
|
||||
"isRenamed": false,
|
||||
"isSubmodule": false,
|
||||
"sections": []interface{}{
|
||||
map[string]interface{}{
|
||||
"fileName": "cmd/api/api.go",
|
||||
"name": "",
|
||||
"lines": []interface{}{
|
||||
map[string]interface{}{
|
||||
"leftIdx": float64(0),
|
||||
"rightIdx": float64(0),
|
||||
"type": float64(4),
|
||||
"content": "@@ -1 +1 @@",
|
||||
"sectionInfo": map[string]interface{}{
|
||||
"path": "cmd/api/api.go",
|
||||
"lastLeftIdx": float64(0),
|
||||
"lastRightIdx": float64(0),
|
||||
"leftIdx": float64(1),
|
||||
"rightIdx": float64(1),
|
||||
"leftHunkSize": float64(1),
|
||||
"rightHunkSize": float64(1),
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"leftIdx": float64(0),
|
||||
"rightIdx": float64(1),
|
||||
"type": float64(2),
|
||||
"content": "+package api",
|
||||
"sectionInfo": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/pulls/42/journals.json":
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(301), "note": "needs work"})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review-comment", map[string]string{
|
||||
"id": "42",
|
||||
"review-id": "12",
|
||||
"path": "cmd/api/api.go",
|
||||
"line-code": "abc_0_1",
|
||||
"note": "needs work",
|
||||
"type": "problem",
|
||||
"commit": "deadbeef",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review-comment failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, payload["type"], "problem")
|
||||
assertEqual(t, payload["review_id"], "12")
|
||||
assertEqual(t, payload["line_code"], "abc_0_1")
|
||||
assertEqual(t, payload["commit_id"], "deadbeef")
|
||||
diff, ok := payload["diff"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("diff missing or wrong type: %#v", payload["diff"])
|
||||
}
|
||||
assertEqual(t, diff["name"], "cmd/api/api.go")
|
||||
assertEqual(t, diff["oldname"], "cmd/api/api.go")
|
||||
assertEqual(t, diff["is_created"], false)
|
||||
sections, ok := diff["sections"].([]interface{})
|
||||
if !ok || len(sections) != 1 {
|
||||
t.Fatalf("sections = %#v", diff["sections"])
|
||||
}
|
||||
section := sections[0].(map[string]interface{})
|
||||
assertEqual(t, section["file_name"], "cmd/api/api.go")
|
||||
lines := section["lines"].([]interface{})
|
||||
firstLine := lines[0].(map[string]interface{})
|
||||
assertEqual(t, firstLine["left_index"], float64(0))
|
||||
assertEqual(t, firstLine["right_index"], float64(0))
|
||||
assertEqual(t, firstLine["section_path"], "cmd/api/api.go")
|
||||
secondLine := lines[1].(map[string]interface{})
|
||||
assertEqual(t, secondLine["match"], float64(1))
|
||||
}
|
||||
|
||||
func TestPRReviewCommentCreateDryRunWithDiffFile(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run with diff file should not call API, got %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
diffPath := filepath.Join(t.TempDir(), "diff.json")
|
||||
if err := os.WriteFile(diffPath, []byte(`{
|
||||
"name":"cmd/api/api.go",
|
||||
"old_name":"cmd/api/api.go",
|
||||
"addition":1,
|
||||
"deletion":0,
|
||||
"type":2,
|
||||
"sections":[{"fileName":"cmd/api/api.go","name":"","lines":[{"leftIdx":0,"rightIdx":1,"type":2,"content":"+package api","sectionInfo":null}]}]
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("write diff file: %v", err)
|
||||
}
|
||||
|
||||
err := runPRShortcut(t, server, "review-comment", map[string]string{
|
||||
"id": "42",
|
||||
"review-id": "12",
|
||||
"path": "cmd/api/api.go",
|
||||
"line-code": "abc_0_1",
|
||||
"note": "needs work",
|
||||
"diff-file": diffPath,
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review-comment dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewCommentCreateFailsWhenDiffMissing(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/42/files.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "README.md",
|
||||
"sections": []interface{}{},
|
||||
"addition": float64(1),
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review-comment", map[string]string{
|
||||
"id": "42",
|
||||
"review-id": "12",
|
||||
"path": "cmd/api/api.go",
|
||||
"line-code": "abc_0_1",
|
||||
"note": "needs work",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing diff error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRUpdateReviewComment(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" {
|
||||
t.Fatalf("expected PUT, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/owner/repo/pulls/42/journals/301.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(301), "state": "resolved"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update-review-comment", map[string]string{
|
||||
"id": "42",
|
||||
"comment-id": "301",
|
||||
"note": "fixed",
|
||||
"state": "resolved",
|
||||
"commit": "deadbeef",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update-review-comment failed: %v", err)
|
||||
}
|
||||
assertEqual(t, payload["note"], "fixed")
|
||||
assertEqual(t, payload["state"], "resolved")
|
||||
assertEqual(t, payload["commit_id"], "deadbeef")
|
||||
}
|
||||
|
||||
func TestPRUpdateReviewCommentRequiresChanges(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not call API when no update fields are provided")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update-review-comment", map[string]string{
|
||||
"id": "42",
|
||||
"comment-id": "301",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRDeleteReviewComment(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
t.Fatalf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/owner/repo/pulls/42/journals/301.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "delete-review-comment", map[string]string{
|
||||
"id": "42",
|
||||
"comment-id": "301",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete-review-comment failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- list ---
|
||||
|
||||
func TestPRList(t *testing.T) {
|
||||
|
|
@ -422,51 +157,6 @@ func TestPRListWithFilters(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPRListWithLoginFilter(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/owner/repo/pulls.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"pulls": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"title": "PR by alice",
|
||||
"issue": map[string]interface{}{
|
||||
"author": map[string]interface{}{"login": "alice"},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": float64(2),
|
||||
"title": "PR by bob",
|
||||
"issue": map[string]interface{}{
|
||||
"author": map[string]interface{}{"login": "bob"},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": float64(3),
|
||||
"title": "PR by Alice (uppercase)",
|
||||
"issue": map[string]interface{}{
|
||||
"author": map[string]interface{}{"login": "Alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"total_count": float64(3),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "list", map[string]string{
|
||||
"state": "open",
|
||||
"login": "alice",
|
||||
"page": "1",
|
||||
"limit": "20",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list with login filter failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRListStateAllOmitsStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/owner/repo/pulls.json" {
|
||||
|
|
@ -485,50 +175,6 @@ func TestPRListStateAllOmitsStatus(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNormalizePullRequestListNumbersCopiesIndex(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{
|
||||
"pulls": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(11),
|
||||
"index": float64(7),
|
||||
"title": "feat: show number",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
normalizePullRequestListNumbers(env)
|
||||
|
||||
data := env.Data.(map[string]interface{})
|
||||
pulls := data["pulls"].([]interface{})
|
||||
pr := pulls[0].(map[string]interface{})
|
||||
assertEqual(t, pr["number"], float64(7))
|
||||
}
|
||||
|
||||
func TestNormalizePullRequestListNumbersKeepsExistingNumber(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{
|
||||
"pulls": []interface{}{
|
||||
map[string]interface{}{
|
||||
"number": float64(9),
|
||||
"index": float64(7),
|
||||
"title": "feat: keep number",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
normalizePullRequestListNumbers(env)
|
||||
|
||||
data := env.Data.(map[string]interface{})
|
||||
pulls := data["pulls"].([]interface{})
|
||||
pr := pulls[0].(map[string]interface{})
|
||||
assertEqual(t, pr["number"], float64(9))
|
||||
}
|
||||
|
||||
// --- create ---
|
||||
|
||||
func TestPRCreate(t *testing.T) {
|
||||
|
|
@ -563,10 +209,6 @@ func TestPRCreate(t *testing.T) {
|
|||
func TestPRCreateNoBody(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/owner/repo.json" {
|
||||
writeJSON(t, w, map[string]interface{}{"default_branch": "main"})
|
||||
return
|
||||
}
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(43), "title": "feat: nob"})
|
||||
}))
|
||||
|
|
@ -582,9 +224,6 @@ func TestPRCreateNoBody(t *testing.T) {
|
|||
if _, ok := payload["body"]; ok {
|
||||
t.Fatal("body should not be in payload when not provided")
|
||||
}
|
||||
if payload["base"] != "main" {
|
||||
t.Fatalf("expected base to fall back to default branch main, got %v", payload["base"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- view ---
|
||||
|
|
@ -610,42 +249,199 @@ func TestPRView(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPRViewSurfacesMergedAt(t *testing.T) {
|
||||
func TestEnrichPullRequestTimestampsPromotesMergedAndClosedAt(t *testing.T) {
|
||||
var journalCalls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/owner/repo/pulls/42.json" {
|
||||
if r.Method != "GET" {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/owner/repo/issues/142349/journals.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
journalCalls++
|
||||
writeJSON(t, w, map[string]interface{}{"journals": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
env := &output.Envelope{Data: map[string]interface{}{
|
||||
"pull_request": map[string]interface{}{
|
||||
"pull_request_status": float64(1),
|
||||
"merged_at": "2026-05-14T14:26:27+08:00",
|
||||
"created_at": "2026-05-10T09:00:00+08:00",
|
||||
},
|
||||
"issue": map[string]interface{}{
|
||||
"id": float64(142349),
|
||||
"created_at": "2026-05-10T09:00:00+08:00",
|
||||
},
|
||||
}}
|
||||
|
||||
if err := enrichPullRequestTimestamps(ctx, env); err != nil {
|
||||
t.Fatalf("enrichPullRequestTimestamps returned error: %v", err)
|
||||
}
|
||||
|
||||
data := env.Data.(map[string]interface{})
|
||||
pr := data["pull_request"].(map[string]interface{})
|
||||
issue := data["issue"].(map[string]interface{})
|
||||
assertEqual(t, data["created_at"], "2026-05-10T09:00:00+08:00")
|
||||
assertEqual(t, data["merged_at"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, data["closed_at"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, data["closed_on"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, pr["merged_at"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, pr["closed_at"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, issue["closed_on"], "2026-05-14T14:26:27+08:00")
|
||||
if journalCalls != 1 {
|
||||
t.Fatalf("journalCalls = %d, want 1", journalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPullRequestTimestampsReadsMergeTimeFromJournals(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/owner/repo/issues/142349/journals.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(42),
|
||||
"pull_request": map[string]interface{}{
|
||||
"merged_at": "2026-07-05T12:52:05+08:00",
|
||||
"merged": true,
|
||||
"pull_request_staus": "merged",
|
||||
"journals": []interface{}{
|
||||
map[string]interface{}{
|
||||
"operate_category": "status",
|
||||
"operate_content": "<b>合并了</b> 合并请求",
|
||||
"updated_at": "2026-05-14T14:26:27+08:00",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", "/owner/repo/pulls/42", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CallAPI error: %v", err)
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
env := &output.Envelope{Data: map[string]interface{}{
|
||||
"pull_request": map[string]interface{}{
|
||||
"pull_request_staus": "merged",
|
||||
},
|
||||
"issue": map[string]interface{}{
|
||||
"id": float64(142349),
|
||||
},
|
||||
}}
|
||||
|
||||
if err := enrichPullRequestTimestamps(ctx, env); err != nil {
|
||||
t.Fatalf("enrich error: %v", err)
|
||||
t.Fatalf("enrichPullRequestTimestamps returned error: %v", err)
|
||||
}
|
||||
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("unexpected data type: %T", env.Data)
|
||||
data := env.Data.(map[string]interface{})
|
||||
pr := data["pull_request"].(map[string]interface{})
|
||||
issue := data["issue"].(map[string]interface{})
|
||||
assertEqual(t, data["merged_at"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, data["closed_at"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, data["closed_on"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, pr["merged_at"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, pr["closed_at"], "2026-05-14T14:26:27+08:00")
|
||||
assertEqual(t, issue["closed_on"], "2026-05-14T14:26:27+08:00")
|
||||
}
|
||||
|
||||
func TestEnrichPullRequestTimestampsReadsClosedTimeFromJournals(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/owner/repo/issues/142350/journals.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"journals": []interface{}{
|
||||
map[string]interface{}{
|
||||
"operate_category": "status",
|
||||
"operate_content": "<b>关闭了</b>合并请求",
|
||||
"created_at": "2026-05-15T10:30:00+08:00",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
env := &output.Envelope{Data: map[string]interface{}{
|
||||
"pull_request": map[string]interface{}{
|
||||
"pull_request_status": float64(2),
|
||||
},
|
||||
"issue": map[string]interface{}{
|
||||
"id": float64(142350),
|
||||
},
|
||||
}}
|
||||
|
||||
if err := enrichPullRequestTimestamps(ctx, env); err != nil {
|
||||
t.Fatalf("enrichPullRequestTimestamps returned error: %v", err)
|
||||
}
|
||||
|
||||
data := env.Data.(map[string]interface{})
|
||||
pr := data["pull_request"].(map[string]interface{})
|
||||
issue := data["issue"].(map[string]interface{})
|
||||
assertEqual(t, data["closed_at"], "2026-05-15T10:30:00+08:00")
|
||||
assertEqual(t, data["closed_on"], "2026-05-15T10:30:00+08:00")
|
||||
assertEqual(t, pr["closed_at"], "2026-05-15T10:30:00+08:00")
|
||||
assertEqual(t, issue["closed_on"], "2026-05-15T10:30:00+08:00")
|
||||
if _, ok := data["merged_at"]; ok {
|
||||
t.Fatalf("merged_at should stay empty for closed pull requests, got %v", data["merged_at"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPullRequestTimestampsSkipsJournalsForOpenPR(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
return
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
env := &output.Envelope{Data: map[string]interface{}{
|
||||
"pull_request": map[string]interface{}{
|
||||
"pull_request_status": float64(0),
|
||||
},
|
||||
"issue": map[string]interface{}{
|
||||
"id": float64(142351),
|
||||
},
|
||||
}}
|
||||
|
||||
if err := enrichPullRequestTimestamps(ctx, env); err != nil {
|
||||
t.Fatalf("enrichPullRequestTimestamps returned error: %v", err)
|
||||
}
|
||||
|
||||
data := env.Data.(map[string]interface{})
|
||||
if _, ok := data["closed_at"]; ok {
|
||||
t.Fatalf("closed_at should not be set for open pull requests, got %v", data["closed_at"])
|
||||
}
|
||||
if _, ok := data["merged_at"]; ok {
|
||||
t.Fatalf("merged_at should not be set for open pull requests, got %v", data["merged_at"])
|
||||
}
|
||||
assertEqual(t, data["merged_at"], "2026-07-05T12:52:05+08:00")
|
||||
assertEqual(t, data["merged"], true)
|
||||
}
|
||||
|
||||
// --- merge ---
|
||||
|
|
@ -750,151 +546,6 @@ func TestPRDiff(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- edit ---
|
||||
|
||||
func editServer(t *testing.T, current map[string]interface{}, put *map[string]interface{}) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/owner/repo/pulls/42.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
writeJSON(t, w, current)
|
||||
case "PUT":
|
||||
*put = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
default:
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestPREditPreservesUnspecifiedFields(t *testing.T) {
|
||||
var put map[string]interface{}
|
||||
server := editServer(t, map[string]interface{}{
|
||||
"id": float64(42),
|
||||
"title": "original title",
|
||||
"body": "original body",
|
||||
"head": "feature/x",
|
||||
"base": "master",
|
||||
"issue": map[string]interface{}{
|
||||
"id": float64(105),
|
||||
"issue_tags": []interface{}{
|
||||
map[string]interface{}{"id": float64(7), "name": "bug"},
|
||||
map[string]interface{}{"id": float64(9), "name": "urgent"},
|
||||
},
|
||||
},
|
||||
}, &put)
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "edit", map[string]string{
|
||||
"id": "42",
|
||||
"body": "updated body",
|
||||
"base": "develop",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit failed: %v", err)
|
||||
}
|
||||
if put == nil {
|
||||
t.Fatal("PUT was not called")
|
||||
}
|
||||
assertEqual(t, put["title"], "original title")
|
||||
assertEqual(t, put["body"], "updated body")
|
||||
assertEqual(t, put["head"], "feature/x")
|
||||
assertEqual(t, put["base"], "develop")
|
||||
|
||||
tags, ok := put["issue_tag_ids"].([]interface{})
|
||||
if !ok || len(tags) != 2 || fmt.Sprintf("%v", tags[0]) != "7" || fmt.Sprintf("%v", tags[1]) != "9" {
|
||||
t.Fatalf("expected preserved tag ids [7 9], got %v", put["issue_tag_ids"])
|
||||
}
|
||||
if _, ok := put["receivers_login"].([]interface{}); !ok {
|
||||
t.Fatalf("expected receivers_login array, got %v", put["receivers_login"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPREditTagIDsOverride(t *testing.T) {
|
||||
var put map[string]interface{}
|
||||
server := editServer(t, map[string]interface{}{
|
||||
"title": "keep",
|
||||
"body": "keep",
|
||||
"head": "src",
|
||||
"base": "master",
|
||||
"issue": map[string]interface{}{
|
||||
"issue_tags": []interface{}{
|
||||
map[string]interface{}{"id": float64(7)},
|
||||
},
|
||||
},
|
||||
}, &put)
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "edit", map[string]string{
|
||||
"id": "42",
|
||||
"tag-ids": "3, 5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit failed: %v", err)
|
||||
}
|
||||
tags, ok := put["issue_tag_ids"].([]interface{})
|
||||
if !ok || len(tags) != 2 || fmt.Sprintf("%v", tags[0]) != "3" || fmt.Sprintf("%v", tags[1]) != "5" {
|
||||
t.Fatalf("expected tag ids [3 5], got %v", put["issue_tag_ids"])
|
||||
}
|
||||
}
|
||||
|
||||
// The non-v1 detail endpoint nests the branch names under pull_request.head /
|
||||
// pull_request.base (branch-name strings), which is the shape the live API
|
||||
// actually returns; editing title-only must still preserve them.
|
||||
func TestPREditReadsNestedPullRequestFields(t *testing.T) {
|
||||
var put map[string]interface{}
|
||||
server := editServer(t, map[string]interface{}{
|
||||
"pull_request": map[string]interface{}{
|
||||
"title": "nested title",
|
||||
"body": "nested body",
|
||||
"head": "topic",
|
||||
"base": "main",
|
||||
},
|
||||
"issue": map[string]interface{}{"id": float64(1)},
|
||||
}, &put)
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "edit", map[string]string{
|
||||
"id": "42",
|
||||
"title": "new title",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit failed: %v", err)
|
||||
}
|
||||
assertEqual(t, put["title"], "new title")
|
||||
assertEqual(t, put["body"], "nested body")
|
||||
assertEqual(t, put["head"], "topic")
|
||||
assertEqual(t, put["base"], "main")
|
||||
}
|
||||
|
||||
func TestPREditRequiresAtLeastOneField(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("no request expected, got %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "edit", map[string]string{"id": "42"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no edit fields are provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPREditHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("server error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "edit", map[string]string{"id": "42", "title": "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 500")
|
||||
}
|
||||
}
|
||||
|
||||
// --- extractIssueID ---
|
||||
|
||||
func TestExtractIssueID(t *testing.T) {
|
||||
|
|
@ -1025,165 +676,6 @@ func TestPRDiffHTTPError(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- status ---
|
||||
|
||||
func TestPRStatusGrouping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
openPulls []interface{}
|
||||
reviewPulls []interface{}
|
||||
wantCreated []string
|
||||
wantReview []string
|
||||
}{
|
||||
{
|
||||
name: "groups created and review-requested",
|
||||
openPulls: []interface{}{
|
||||
makePull(1, "mine one", "currentuser"),
|
||||
makePull(2, "theirs", "someoneelse"),
|
||||
makePull(3, "mine two", "currentuser"),
|
||||
},
|
||||
reviewPulls: []interface{}{
|
||||
makePull(4, "review me", "author4"),
|
||||
},
|
||||
wantCreated: []string{"mine one", "mine two"},
|
||||
wantReview: []string{"review me"},
|
||||
},
|
||||
{
|
||||
name: "author filter excludes other people",
|
||||
openPulls: []interface{}{
|
||||
makePull(2, "theirs", "someoneelse"),
|
||||
},
|
||||
reviewPulls: []interface{}{},
|
||||
wantCreated: []string{},
|
||||
wantReview: []string{},
|
||||
},
|
||||
{
|
||||
name: "empty repository yields empty groups",
|
||||
openPulls: []interface{}{},
|
||||
reviewPulls: []interface{}{},
|
||||
wantCreated: []string{},
|
||||
wantReview: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var reviewerID string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/users/me.json":
|
||||
writeJSON(t, w, map[string]interface{}{"login": "currentuser", "id": float64(7)})
|
||||
case r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
if got := r.URL.Query().Get("status"); got != "0" {
|
||||
t.Fatalf("expected status=0, got %q", got)
|
||||
}
|
||||
if rid := r.URL.Query().Get("reviewer_id"); rid != "" {
|
||||
reviewerID = rid
|
||||
writeJSON(t, w, map[string]interface{}{"pulls": tt.reviewPulls})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"pulls": tt.openPulls})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := collectPullStatus(statusContext(server))
|
||||
if err != nil {
|
||||
t.Fatalf("collectPullStatus failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, result["login"], "currentuser")
|
||||
assertPullTitles(t, result["created"], tt.wantCreated)
|
||||
assertPullTitles(t, result["review_requested"], tt.wantReview)
|
||||
if reviewerID != "7" {
|
||||
t.Fatalf("expected reviewer_id=7 sent to API, got %q", reviewerID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRStatusPropagatesUserError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("server error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := collectPullStatus(statusContext(server)); err == nil {
|
||||
t.Fatal("expected error when /users/me fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRStatusMissingLogin(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(7)})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := collectPullStatus(statusContext(server)); err == nil {
|
||||
t.Fatal("expected error when login is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRStatusRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/users/me.json":
|
||||
writeJSON(t, w, map[string]interface{}{"login": "currentuser", "id": float64(7)})
|
||||
case "/v1/owner/repo/pulls.json":
|
||||
writeJSON(t, w, map[string]interface{}{"pulls": []interface{}{}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runPRShortcut(t, server, "status", nil); err != nil {
|
||||
t.Fatalf("status run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func statusContext(server *httptest.Server) *common.RuntimeContext {
|
||||
return &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
}
|
||||
}
|
||||
|
||||
func makePull(id int, title, authorLogin string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": float64(id),
|
||||
"title": title,
|
||||
"issue": map[string]interface{}{
|
||||
"author": map[string]interface{}{"login": authorLogin},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertPullTitles(t *testing.T, raw interface{}, want []string) {
|
||||
t.Helper()
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected []interface{}, got %T", raw)
|
||||
}
|
||||
if len(items) != len(want) {
|
||||
t.Fatalf("expected %d pulls, got %d", len(want), len(items))
|
||||
}
|
||||
for i, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("pull %d not a map: %T", i, it)
|
||||
}
|
||||
if got := stringField(m, "title"); got != want[i] {
|
||||
t.Fatalf("pull %d title = %q, want %q", i, got, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findPRShortcut(t, name)
|
||||
|
|
@ -1234,60 +726,3 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
|||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCommentsUsesPullJournalsEndpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/382/journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"journals": []interface{}{}, "total_count": float64(0)})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runPRShortcut(t, server, "comments", map[string]string{"id": "382"}); err != nil {
|
||||
t.Fatalf("comments failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCommentEditSendsNoteAndState(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/pulls/382/journals/484052.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(484052)})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
args := map[string]string{"id": "382", "comment-id": "484052", "body": "edited", "state": "resolved"}
|
||||
if err := runPRShortcut(t, server, "comment-edit", args); err != nil {
|
||||
t.Fatalf("comment-edit failed: %v", err)
|
||||
}
|
||||
assertEqual(t, payload["note"], "edited")
|
||||
assertEqual(t, payload["state"], "resolved")
|
||||
|
||||
args["state"] = "bogus"
|
||||
if err := runPRShortcut(t, server, "comment-edit", args); err == nil {
|
||||
t.Fatal("expected error for invalid --state")
|
||||
}
|
||||
args["state"] = "opened"
|
||||
args["comment-id"] = "abc"
|
||||
if err := runPRShortcut(t, server, "comment-edit", args); err == nil {
|
||||
t.Fatal("expected error for non-integer --comment-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCommentDeleteUsesPullJournalsEndpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/pulls/382/journals/484052.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runPRShortcut(t, server, "comment-delete", map[string]string{"id": "382", "comment-id": "484052"}); err != nil {
|
||||
t.Fatalf("comment-delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue