gitlink-cli/shortcuts/pr/pr.go

775 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pr
import (
"fmt"
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchMergeShortcut(),
// +list列出当前仓库的合并请求支持按状态/里程碑/标签/评审人筛选)
{
Name: "list",
Description: "列出合并请求",
Long: `List pull requests in the current repository.
Shows PR number, title, status, author, and other details.
Use --state to filter by status (open, merged, closed).
Use --keyword, --milestone, --tag, --reviewer, --assignee for
additional filtering, and --sort to control ordering.`,
Example: ` # List open pull requests
gitlink pr +list
# List merged pull requests
gitlink pr +list --state merged
# List closed pull requests with pagination
gitlink pr +list --state closed --page 2 --limit 10
# Search by keyword
gitlink pr +list -k "feature"
# Filter by milestone and assignee
gitlink pr +list --milestone 3 --assignee 5
# Sort by created date
gitlink pr +list --sort created_on
# Sort by updated date descending
gitlink pr +list --sort updated_on --sort-direction desc`,
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
{Name: "milestone", Short: "m", Usage: "Filter by milestone ID"},
{Name: "tag", Usage: "Filter by tag ID"},
{Name: "reviewer", Usage: "Filter by reviewer ID"},
{Name: "assignee", Short: "a", Usage: "Filter by assignee ID"},
{Name: "sort", Usage: "Sort field (e.g. created_on, 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
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
}
// [P2 参数补全] 以下 7 个查询参数原 CLI 未暴露
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k) // 关键词搜索
}
if m := ctx.Arg("milestone"); m != "" {
q.Set("version_id", m) // CLI "milestone" → API "version_id"PR 的里程碑字段名和 issue 不同)
}
if t := ctx.Arg("tag"); t != "" {
q.Set("issue_tag_id", t) // CLI "tag" → API "issue_tag_id"
}
if r := ctx.Arg("reviewer"); r != "" {
q.Set("reviewer_id", r) // CLI "reviewer" → API "reviewer_id"
}
if a := ctx.Arg("assignee"); a != "" {
q.Set("assign_user_id", a) // CLI "assignee" → API "assign_user_id"
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s) // 排序字段
}
if d := ctx.Arg("sort-direction"); d != "" {
q.Set("sort_direction", d) // 排序方向
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create创建新合并请求需 --title 和 --head 源分支,目标分支默认仓库默认分支)
{
Name: "create",
Description: "创建合并请求",
Long: `Create a new pull request in the current repository.
Requires a title and source branch (--head). The target branch
(--base) defaults to the repository default branch if not specified.
Use --assignee, --milestone, --label, and --priority to set
additional fields at creation time.`,
Example: ` # Create a pull request
gitlink pr +create --title "Add new feature" --head feature-branch
# Create a PR with description and target branch
gitlink pr +create --title "Fix bug" --body "Fixes #123" --head fix-branch --base main
# Create with assignee and milestone
gitlink pr +create --title "Feature" --head feat --assignee 5 --milestone 3
# Create with labels and priority
gitlink pr +create --title "Feature" --head feat --label 7 --priority 1`,
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "PR title", Required: true},
{Name: "body", Short: "b", Usage: "PR description"},
{Name: "head", Usage: "Source branch", Required: true},
{Name: "base", Usage: "Target branch (default: repository default branch)"},
{Name: "priority", Usage: "Priority ID"},
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
{Name: "label", Short: "l", Usage: "Label ID (comma-separated for multiple)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, _ := ctx.RequireArg("title")
head, _ := ctx.RequireArg("head")
base := ctx.Arg("base")
// [P1 Bug#2] 原代码: base = "master"(硬编码)
// 修复: 使用 GetDefaultBranch() 动态查询仓库默认分支
if base == "" {
base = common.GetDefaultBranch(ctx)
}
payload := map[string]interface{}{
"title": title,
"head": head,
"base": base,
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
// [P2 参数补全] PR 创建时支持 priority、assignee、milestone、label
if p := ctx.Arg("priority"); p != "" {
payload["priority_id"] = p // CLI "priority" → API "priority_id"
}
if a := ctx.Arg("assignee"); a != "" {
payload["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
}
if m := ctx.Arg("milestone"); m != "" {
payload["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
}
// [P2 参数补全] PR 的标签需要数组格式 issue_tag_ids和 issue 的单值 issue_tag_id 不同)
// 支持逗号分隔多标签:--label "1,2,3" → [1, 2, 3]
if l := ctx.Arg("label"); l != "" {
if strings.Contains(l, ",") {
// 多标签:拆分、去空格、转为 []interface{} 数组
parts := strings.Split(l, ",")
ids := make([]interface{}, len(parts))
for i, p := range parts {
ids[i] = strings.TrimSpace(p)
}
payload["issue_tag_ids"] = ids
} else {
// 单标签也用数组格式
payload["issue_tag_ids"] = []interface{}{l}
}
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +view查看指定合并请求的详情标题/描述/状态/作者/分支等)
{
Name: "view",
Description: "查看合并请求详情",
Long: `View detailed information about a specific pull request.
Displays PR title, description, status, author, branches,
and other metadata.`,
Example: ` # View details of pull request #42
gitlink pr +view --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +merge合并合并请求支持 merge/rebase/squash 三种方式,可自定义提交信息)
{
Name: "merge",
Description: "合并合并请求",
Long: `Merge a pull request using the specified merge method.
Supported merge methods: merge (default), rebase, squash.
Use --title and --body to customize the merge commit message.`,
Example: ` # Merge a pull request using the default method
gitlink pr +merge --id 42
# Squash-merge a pull request
gitlink pr +merge --id 42 --method squash
# Rebase-merge a pull request
gitlink pr +merge --id 42 --method rebase
# Merge with custom commit title and description
gitlink pr +merge --id 42 --title "Merge feature X" --body "Combines feature X into main"`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
{Name: "title", Usage: "Merge commit title"},
{Name: "body", Usage: "Merge commit description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
method := ctx.Arg("method")
if method == "" {
method = "merge"
}
if err := validateMergeMethod(method); err != nil {
return err
}
payload := map[string]interface{}{
"do": method,
}
if t := ctx.Arg("title"); t != "" {
payload["title"] = t
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +close关闭合并请求不合并关闭后需重新开启才能合并
{
Name: "close",
Description: "关闭合并请求",
Long: `Close a pull request without merging.
The PR will be marked as closed and cannot be merged afterwards
unless it is reopened.`,
Example: ` # Close pull request #42
gitlink pr +close --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +files列出合并请求中的变更文件路径/变更类型/增删行数)
{
Name: "files",
Description: "列出合并请求的变更文件",
Long: `List all files changed in a pull request.
Shows the file path, change type (added, modified, deleted),
and the number of additions and deletions for each file.`,
Example: ` # List files changed in pull request #42
gitlink pr +files --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +versions列出合并请求的补丁集版本历史
{
Name: "versions",
Description: "列出合并请求的补丁集版本",
Long: `List all patchset versions of a pull request.
Each time a PR is updated with new commits, a new patchset version
is created. This command shows the version history.`,
Example: ` # List patchset versions for pull request #42
gitlink pr +versions --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/versions", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// [P1 Bug#3 修复] 原代码中 +diff 和 +files 调用了同一个 API 端点,功能完全重复
// 修复: 将 +diff 改名为 +version-diff指向 PR 补丁集版本的差异 API
// +version-diff查看合并请求某补丁集版本的差异可用 --file 过滤指定文件)
// +files 仍然用于查看 PR 文件列表,两者功能区分开
{
Name: "version-diff",
Description: "查看合并请求补丁集版本的差异",
Long: `Show the diff for a specific patchset version of a pull request.
Use --file to filter the diff to a specific file path.`,
Example: ` # Show diff for version 3 of pull request #42
gitlink pr +version-diff --id 42 --version-id 3
# Show diff for a specific file in a patchset version
gitlink pr +version-diff --id 42 --version-id 3 --file src/main.go`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
{Name: "file", Short: "f", Usage: "Filter diff by file path"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
versionID, err := ctx.RequireArg("version-id")
if err != nil {
return err
}
path := fmt.Sprintf("%s/versions/%s/diff", prV1Path(ctx, id), versionID)
if file := ctx.Arg("file"); file != "" {
q := url.Values{}
q.Set("filepath", file)
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
}
return ctx.Output(env)
}
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +reviews列出合并请求的评审记录可按评审状态筛选
{
Name: "reviews",
Description: "列出合并请求的评审",
Long: `List all reviews submitted for a pull request.
Use --status to filter reviews by their status:
common, approved, or rejected.`,
Example: ` # List all reviews for pull request #42
gitlink pr +reviews --id 42
# List only approved reviews
gitlink pr +reviews --id 42 --status approved`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
},
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 status := ctx.Arg("status"); status != "" {
if err := validatePRReviewStatus(status); err != nil {
return err
}
q.Set("status", status)
}
env, err := ctx.CallAPIWithQuery("GET", prV1Path(ctx, id)+"/reviews", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +review对合并请求提交评审common/approved/rejected支持 --dry-run 预览)
{
Name: "review",
Description: "创建合并请求评审",
Long: `Submit a review on a pull request.
Supported review statuses: common (comment), approved, rejected.
Use --commit to attach the review to a specific commit.
Use --dry-run to preview the review without submitting it.`,
Example: ` # Submit an approving review
gitlink pr +review --id 42 --status approved --content "Looks good!"
# Request changes on a PR
gitlink pr +review --id 42 --status rejected --content "Needs fixes"
# Preview a review without creating it
gitlink pr +review --id 42 --status common --content "Note" --dry-run`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
{Name: "content", Short: "c", Usage: "Review content", Required: true},
{Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"},
{Name: "dry-run", Usage: "Preview the review request without creating it", 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
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
status := ctx.Arg("status")
if status == "" {
status = "common"
}
if err := validatePRReviewStatus(status); err != nil {
return err
}
payload := map[string]interface{}{
"content": content,
"status": status,
}
if commit := ctx.Arg("commit"); commit != "" {
payload["commit_id"] = commit
}
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",
"payload": payload,
})
}
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reviews", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +comments列出合并请求下的评审评论
{
Name: "comments",
Description: "列出合并请求的评审评论",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/journals", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create-comment在合并请求下创建一条评审评论可附行号与文件路径
{
Name: "create-comment",
Description: "创建合并请求的评审评论",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
{Name: "line", Short: "l", Usage: "Line number"},
{Name: "path", Short: "p", Usage: "File path"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
if line := ctx.Arg("line"); line != "" {
payload["line"] = line
}
if path := ctx.Arg("path"); path != "" {
payload["path"] = path
}
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/journals", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update-comment修改合并请求下的某条评审评论
{
Name: "update-comment",
Description: "修改合并请求的评审评论",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
},
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
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete-comment删除合并请求下的某条评审评论
{
Name: "delete-comment",
Description: "删除合并请求的评审评论",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
},
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
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +commits列出合并请求中包含的提交记录
{
Name: "commits",
Description: "列出合并请求的提交",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +reopen重新打开已关闭的合并请求
{
Name: "reopen",
Description: "重新打开合并请求",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reopen", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update更新合并请求的标题和/或描述
{
Name: "update",
Description: "更新合并请求的标题或描述",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "title", Short: "t", Usage: "New title"},
{Name: "body", Short: "b", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
title := ctx.Arg("title")
body := ctx.Arg("body")
if title == "" && body == "" {
return fmt.Errorf("at least one of --title or --body is required")
}
payload := map[string]interface{}{}
if title != "" {
payload["title"] = title
}
if body != "" {
payload["body"] = body
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +comment在合并请求下添加评论作为关联疑修的日志条目发布
{
Name: "comment",
Description: "在合并请求下添加评论",
Long: `Add a comment to a pull request.
The comment is posted as a journal entry on the underlying issue
associated with the pull request.`,
Example: ` # Add a comment to pull request #42
gitlink pr +comment --id 42 --body "This looks great, thanks!"`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
body, _ := ctx.RequireArg("body")
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("fetch PR: %w", err)
}
issueID, err := extractIssueID(prEnv)
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func prV1Path(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id)
}
func validatePRReviewStatus(status string) error {
switch status {
case "common", "approved", "rejected":
return nil
default:
return fmt.Errorf("invalid --status value %q: use common, approved, or rejected", status)
}
}
// [P2 参数补全] 校验合并方式参数,只允许 merge/rebase/squash 三种合法值
// 这是纯前端校验,在发送 API 请求之前拦截非法输入
func validateMergeMethod(method string) error {
switch method {
case "merge", "rebase", "squash":
return nil
default:
return fmt.Errorf("invalid --method %q: use merge, rebase, or squash", method)
}
}
func extractIssueID(env *output.Envelope) (int64, error) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected PR response format")
}
issue, ok := data["issue"].(map[string]interface{})
if !ok {
return 0, fmt.Errorf("PR response missing issue field")
}
idFloat, ok := issue["id"].(float64)
if !ok {
return 0, fmt.Errorf("PR response missing issue.id field")
}
return int64(idFloat), nil
}