Merge PR #194: fix(pr): 补齐 pr +view 的合并与关闭时间字段

# Conflicts:
#	shortcuts/pr/pr.go
#	shortcuts/pr/pr_test.go
This commit is contained in:
wbtiger 2026-07-14 22:49:55 +08:00
commit 32494e25c3
5 changed files with 349 additions and 1302 deletions

View File

@ -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

View File

@ -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
# 对于已合并或已关闭的 PRJSON 输出会尽量补齐 `created_at`、`merged_at`、`closed_at` 和 `closed_on`
# 在本地检出 PR 分支(对标 `gh pr checkout`;需在 git 克隆目录内执行)
gitlink-cli pr +checkout --owner Gitlink --repo forgeplus -i 42

View File

@ -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`

View File

@ -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)
}
}

View File

@ -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)
}
}