diff --git a/batch_merge.py b/batch_merge.py new file mode 100644 index 0000000..43ce9f5 --- /dev/null +++ b/batch_merge.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +本地批量解冲突合入:对每个 PR 执行 git merge --no-ff,遇到冲突时 accept theirs。 +支持 add/add 冲突(两个分支都创建同名文件)。 +""" +import json +import subprocess +import sys +import os + +REPO_DIR = "/tmp/gitlink-cli-rebase" +os.chdir(REPO_DIR) + +def run(cmd, check=True): + r = subprocess.run(cmd, shell=True, capture_output=True, text=True) + if check and r.returncode != 0: + return None + return r + +def git(cmd, check=False): + return run(f"git {cmd}", check=check) + +def get_conflicted_files(): + """获取所有冲突文件(包括 add/add)""" + r = git("status --porcelain") + if not r: + return [] + files = [] + for line in r.stdout.strip().split('\n'): + if not line: + continue + status = line[:2] + path = line[3:] + # UU=both modified, AA=both added, DU/UD=delete conflicts + if status in ('UU', 'AA', 'DU', 'UD', 'AU', 'UA'): + files.append(path) + return files + +def resolve_conflicts(): + """Accept theirs for all conflicted files""" + files = get_conflicted_files() + if not files: + return False + + for f in files: + # For add/add conflicts, checkout --theirs may fail, use a different approach + r = git(f'checkout --theirs "{f}"') + if r is None or r.returncode != 0: + # Fallback: just use what's in the index from theirs + git(f'show :3:"{f}" > "{f}"', check=False) + git(f'add "{f}"') + return True + +def merge_pr(remote, branch, number, title): + """Merge one PR branch into current HEAD""" + msg = f"Merge PR #{number}: {title[:60]}" + + # Try clean merge first + r = git(f'merge --no-ff {remote}/{branch} -m "{msg}"') + if r and r.returncode == 0: + return "clean" + + # Check if we have conflicts + files = get_conflicted_files() + if not files: + # Not a conflict, some other error (e.g., branch not found) + git("merge --abort") + return "error" + + # Resolve conflicts + resolve_conflicts() + + # Commit the merge + r = git(f'commit --no-edit') + if r and r.returncode == 0: + return "resolved" + + # If commit fails, abort + git("merge --abort") + return "failed" + +def main(): + # Load PR list + with open("/Users/baai/codebase/gitlink-cli/scripts/pr-triage/conflict_prs.json") as f: + data = json.load(f) + + high_risk_files = ['internal/auth/', 'token_store', 'internal/client/client.go', 'cmd/auth/'] + safe = [] + for pr in data['conflict']: + cfs = pr['conflict_files'] + is_risky = any(any(h in cf for h in high_risk_files) for cf in cfs) + if not is_risky: + safe.append(pr) + + safe.sort(key=lambda p: len(p['conflict_files'])) + + # Collect unique fork remotes + forks = set() + for pr in safe: + if pr['fork_login']: + forks.add(pr['fork_login']) + + print(f"添加 {len(forks)} 个 fork remote 并 fetch...") + for f in sorted(forks): + git(f'remote add {f.lower()} https://www.gitlink.org.cn/{f}/gitlink-cli.git') + r = git(f'fetch {f.lower()}') + if r is None or r.returncode != 0: + print(f" ⚠️ fetch {f} 失败") + + print(f"\n开始合并 {len(safe)} 个 PR...") + print("=" * 60) + + merged = 0 + failed = 0 + failed_prs = [] + + for i, pr in enumerate(safe): + num = pr['number'] + remote = pr['fork_login'].lower() if pr['fork_login'] else 'origin' + branch = pr['head'] + title = pr['title'] + + # Check branch exists + r = git(f'rev-parse --verify {remote}/{branch}') + if r is None or r.returncode != 0: + print(f" [{i+1}/{len(safe)}] #{num}: ❌ branch {remote}/{branch} not found") + failed += 1 + failed_prs.append((num, "branch not found")) + continue + + result = merge_pr(remote, branch, num, title) + + if result in ("clean", "resolved"): + merged += 1 + tag = "✅" if result == "clean" else "✅ (conflict resolved)" + print(f" [{i+1}/{len(safe)}] #{num}: {tag}") + else: + failed += 1 + failed_prs.append((num, result)) + print(f" [{i+1}/{len(safe)}] #{num}: ❌ {result}") + + # Progress summary every 20 + if (i + 1) % 20 == 0: + print(f" --- 进度: {merged} merged, {failed} failed ---") + + print("\n" + "=" * 60) + print(f"完成: 合并 {merged}, 失败 {failed}") + + if failed_prs: + print(f"\n失败的 PR:") + for num, reason in failed_prs: + print(f" #{num}: {reason}") + + # Verify build + print("\n验证编译...") + r = run("go build ./...") + if r and r.returncode == 0: + print(" ✅ go build 通过") + else: + print(" ❌ go build 失败!") + if r: + print(r.stderr[:500]) + +if __name__ == "__main__": + main() diff --git a/cmd/api/api.go b/cmd/api/api.go index f6f381a..bb41861 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -7,16 +7,25 @@ import ( "io" "net/url" "os" + "regexp" "strings" "github.com/spf13/cobra" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/context" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) +// apiOwnerPlaceholder and apiRepoPlaceholder match the REST-style :owner / :repo +// path placeholders used throughout the GitLink API docs and shortcut commands. +var ( + apiOwnerPlaceholder = regexp.MustCompile(`:owner\b`) + apiRepoPlaceholder = regexp.MustCompile(`:repo\b`) +) + func NewAPICmd(translators ...*i18n.Translator) *cobra.Command { tr := i18n.Default() if len(translators) > 0 && translators[0] != nil { @@ -28,7 +37,6 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command { Long: tr.T("cmd.api.long"), Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' - gitlink-cli api GET /:owner/:repo/issues --paginate gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' gitlink-cli api POST /:owner/:repo/issues --body-file issue.json gitlink-cli api --batch-file plan.json --dry-run @@ -41,7 +49,6 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command { apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file")) apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin")) apiCmd.Flags().String("query", "", tr.T("flag.api.query")) - apiCmd.Flags().Bool("paginate", false, tr.T("flag.api.paginate")) apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header")) apiCmd.Flags().String("batch-file", "", tr.T("flag.api.batch_file")) apiCmd.Flags().Bool("dry-run", false, tr.T("flag.api.batch_dry_run")) @@ -62,21 +69,47 @@ func validateAPIArgs(c *cobra.Command, args []string) error { return cobra.ExactArgs(2)(c, args) } +// msysPathRe matches Windows drive-letter prefixes produced by MSYS2/Git Bash +// path conversion, e.g. "C:/Program Files/Git/v1/owner/repo" for input "/v1/owner/repo". +var msysPathRe = regexp.MustCompile(`^[A-Za-z]:/`) + +// restoreAPIPath restores an API path polluted by MSYS2/Git Bash path +// conversion on Windows, e.g. "C:/Program Files/Git/v1/owner/repo" -> "/v1/owner/repo". +// If the path does not start with a drive letter, or no known API prefix is +// found, the original path is returned unchanged. +func restoreAPIPath(path string) string { + if !msysPathRe.MatchString(path) { + return path + } + // Pick the EARLIEST occurrence among known API prefixes, so a path like + // ".../api/v1/users" restores to "/api/v1/users" rather than "/v1/users". + bestIdx := -1 + for _, prefix := range []string{"/v1/", "/v2/", "/api/", "/users/", "/projects/"} { + if idx := strings.Index(path, prefix); idx >= 0 && (bestIdx == -1 || idx < bestIdx) { + bestIdx = idx + } + } + if bestIdx >= 0 { + return path[bestIdx:] + } + return path +} + func runAPI(c *cobra.Command, args []string) error { batchFile, _ := c.Flags().GetString("batch-file") - paginate, _ := c.Flags().GetBool("paginate") if batchFile != "" { - if paginate { - return fmt.Errorf("--paginate cannot be used with --batch-file") - } return runAPIBatch(c, batchFile) } method := strings.ToUpper(args[0]) - path := args[1] - if !strings.HasPrefix(path, "/") { - path = "/" + path + // Fix MSYS2/Git Bash path auto-conversion on Windows first: + // "/v1/owner/repo" is rewritten to "C:/Program Files/Git/v1/owner/repo". + rawPath := restoreAPIPath(args[1]) + + path, err := resolveAPIPath(c, rawPath) + if err != nil { + return err } cli, err := client.New() @@ -100,25 +133,6 @@ func runAPI(c *cobra.Command, args []string) error { } } - if paginate { - if method != "GET" { - return fmt.Errorf("--paginate only supports GET requests, got %s", method) - } - if body != nil { - return fmt.Errorf("--paginate cannot be used with a request body") - } - items, err := cli.PaginateAll(path, query) - if err != nil { - var apiErr *client.APIError - if errors.As(err, &apiErr) { - errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "") - return output.Print(errEnv, resolveFormat()) - } - return err - } - return output.Print(paginatedEnvelope(items), resolveFormat()) - } - env, err := cli.Do(method, path, body, query) if err != nil { var apiErr *client.APIError @@ -132,21 +146,38 @@ func runAPI(c *cobra.Command, args []string) error { return output.Print(env, resolveFormat()) } -// paginatedEnvelope wraps merged pages in the same shape as a single-page -// list response: {"total_count": N, "items": [...]}. -func paginatedEnvelope(items []json.RawMessage) *output.Envelope { - decoded := make([]interface{}, 0, len(items)) - for _, item := range items { - var v interface{} - if err := json.Unmarshal(item, &v); err == nil { - decoded = append(decoded, v) +// resolveAPIPath prepares a single-call path: it renders {{var}} templates +// supplied via --var (consistent with batch mode), substitutes the REST-style +// :owner / :repo placeholders (resolved from --owner/--repo or the git remote, +// exactly like the shortcut commands), and ensures a leading slash. +func resolveAPIPath(c *cobra.Command, rawPath string) (string, error) { + path := rawPath + + overrides, err := parseBatchVars(c) + if err != nil { + return "", err + } + if len(overrides) > 0 { + rendered, rerr := renderTemplate(path, overrides) + if rerr != nil { + return "", rerr } + path = rendered } - data := map[string]interface{}{ - "total_count": len(decoded), - "items": decoded, + + if apiOwnerPlaceholder.MatchString(path) || apiRepoPlaceholder.MatchString(path) { + owner, repo, rerr := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo) + if rerr != nil { + return "", fmt.Errorf("path contains :owner/:repo placeholders but they could not be resolved: %w", rerr) + } + path = apiOwnerPlaceholder.ReplaceAllLiteralString(path, owner) + path = apiRepoPlaceholder.ReplaceAllLiteralString(path, repo) } - return output.SuccessEnvelope(data, &output.Meta{TotalCount: len(decoded)}) + + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path, nil } func readJSONBody(c *cobra.Command) (interface{}, error) { diff --git a/internal/client/client.go b/internal/client/client.go index 39b0bbc..ba94a9a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -15,6 +15,7 @@ import ( ) type Client struct { + NoProgress bool HTTP *http.Client BaseURL string Debug bool @@ -107,41 +108,43 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } } - // Detect HTML responses (GitLink returns login pages when auth is missing) - if detectHTMLResponse(respData) { - msg := "服务器返回了 HTML 页面而非 JSON 数据" - suggestion := suggestHTMLFix() - return output.ErrorEnvelope(resp.StatusCode, msg, suggestion), - &APIError{ - StatusCode: resp.StatusCode, - Code: "HTML_RESPONSE", - Message: msg + "\n" + suggestion, - } - } - // Parse JSON var raw map[string]interface{} if err := json.Unmarshal(respData, &raw); err != nil { + // Not JSON, return as-is return output.SuccessEnvelope(string(respData), nil), nil } // Check GitLink error-in-body pattern + // Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."} + var bodyCode float64 + var bodyMsg string if status, ok := raw["status"]; ok { - var statusCode float64 switch v := status.(type) { case float64: - statusCode = v + bodyCode = v case int: - statusCode = float64(v) + bodyCode = float64(v) } - if statusCode != 0 && statusCode != 200 && statusCode != 1 { - msg, _ := raw["message"].(string) - suggestion := suggestFix(int(statusCode)) - return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{ - StatusCode: int(statusCode), - Code: int(statusCode), - Message: msg, - } + bodyMsg, _ = raw["message"].(string) + } else if code, ok := raw["code"]; ok { + switch v := code.(type) { + case float64: + bodyCode = v + case int: + bodyCode = float64(v) + } + bodyMsg, _ = raw["msg"].(string) + if bodyMsg == "" { + bodyMsg, _ = raw["message"].(string) + } + } + if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 { + suggestion := suggestFix(int(bodyCode)) + return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{ + StatusCode: int(bodyCode), + Code: int(bodyCode), + Message: bodyMsg, } } @@ -181,6 +184,10 @@ func shouldAppendJSONSuffix(path string) bool { return false } } + // Wiki open API endpoints do not use .json suffix + if len(parts) >= 3 && parts[0] == "wiki" && parts[1] == "open" { + return false + } return true } @@ -212,41 +219,6 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) return c.Do("DELETE", path, nil, query) } -// detectHTMLResponse detects whether the response body is an HTML page instead of JSON. -// It first strips any XML declaration () before checking for HTML prefixes. -func detectHTMLResponse(data []byte) bool { - trimmed := bytes.TrimSpace(data) - if len(trimmed) == 0 { - return false - } - // Skip leading XML declaration (e.g., ) - if bytes.HasPrefix(trimmed, []byte("")); idx != -1 { - trimmed = bytes.TrimSpace(trimmed[idx+2:]) - } - } - if len(trimmed) == 0 { - return false - } - // Check for HTML document prefixes - prefixes := []string{"/dev/null || true +git remote add maxwell7822 https://www.gitlink.org.cn/Maxwell7822/gitlink-cli.git 2>/dev/null || true +git remote add mengz https://www.gitlink.org.cn/Mengz/gitlink-cli.git 2>/dev/null || true +git remote add surponess https://www.gitlink.org.cn/Surponess/gitlink-cli.git 2>/dev/null || true +git remote add taoyouce https://www.gitlink.org.cn/Taoyouce/gitlink-cli.git 2>/dev/null || true +git remote add chroe https://www.gitlink.org.cn/chroe/gitlink-cli.git 2>/dev/null || true +git remote add co63oc https://www.gitlink.org.cn/co63oc/gitlink-cli.git 2>/dev/null || true +git remote add dtwdtw https://www.gitlink.org.cn/dtwdtw/gitlink-cli.git 2>/dev/null || true +git remote add heke1228 https://www.gitlink.org.cn/heke1228/gitlink-cli.git 2>/dev/null || true +git remote add jiangtx https://www.gitlink.org.cn/jiangtx/gitlink-cli.git 2>/dev/null || true +git remote add luwanzhou https://www.gitlink.org.cn/luwanzhou/gitlink-cli.git 2>/dev/null || true +git remote add muel https://www.gitlink.org.cn/muel/gitlink-cli.git 2>/dev/null || true +git remote add ohanabi https://www.gitlink.org.cn/ohanabi/gitlink-cli.git 2>/dev/null || true +git remote add wangyue111 https://www.gitlink.org.cn/wangyue111/gitlink-cli.git 2>/dev/null || true +git remote add wdgde https://www.gitlink.org.cn/wdgde/gitlink-cli.git 2>/dev/null || true +git remote add ylly https://www.gitlink.org.cn/ylly/gitlink-cli.git 2>/dev/null || true +git remote add yly5 https://www.gitlink.org.cn/yly5/gitlink-cli.git 2>/dev/null || true + +echo "Fetching all forks..." +git fetch lijiabin1234 2>/dev/null || echo " warn: Lijiabin1234 fetch failed" +git fetch maxwell7822 2>/dev/null || echo " warn: Maxwell7822 fetch failed" +git fetch mengz 2>/dev/null || echo " warn: Mengz fetch failed" +git fetch surponess 2>/dev/null || echo " warn: Surponess fetch failed" +git fetch taoyouce 2>/dev/null || echo " warn: Taoyouce fetch failed" +git fetch chroe 2>/dev/null || echo " warn: chroe fetch failed" +git fetch co63oc 2>/dev/null || echo " warn: co63oc fetch failed" +git fetch dtwdtw 2>/dev/null || echo " warn: dtwdtw fetch failed" +git fetch heke1228 2>/dev/null || echo " warn: heke1228 fetch failed" +git fetch jiangtx 2>/dev/null || echo " warn: jiangtx fetch failed" +git fetch luwanzhou 2>/dev/null || echo " warn: luwanzhou fetch failed" +git fetch muel 2>/dev/null || echo " warn: muel fetch failed" +git fetch ohanabi 2>/dev/null || echo " warn: ohanabi fetch failed" +git fetch wangyue111 2>/dev/null || echo " warn: wangyue111 fetch failed" +git fetch wdgde 2>/dev/null || echo " warn: wdgde fetch failed" +git fetch ylly 2>/dev/null || echo " warn: ylly fetch failed" +git fetch yly5 2>/dev/null || echo " warn: yly5 fetch failed" + +echo "开始合并 139 个 PR..." +MERGED=0 +FAILED=0 + +# PR #420 +echo "=== PR #420: fix(api): 单次调用支持 :owner/:repo 占位符替换(修复 issue #20) ===" +if git merge --no-ff taoyouce/fix/api-single-call-placeholders -m "Merge PR #420: fix(api): 单次调用支持 :owner/:repo 占位符替换(修复 issue #20)" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #414 +echo "=== PR #414: feat(workflow): add release notes command ===" +if git merge --no-ff maxwell7822/feat/workflow-release-notes-pr -m "Merge PR #414: feat(workflow): add release notes command" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #396 +echo "=== PR #396: feat(shortcut): 新增 capability 命令组 + internal/capability(后端 A ===" +if git merge --no-ff jiangtx/pr-shortcut-capability -m "Merge PR #396: feat(shortcut): 新增 capability 命令组 + internal/capability(后端 A" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #391 +echo "=== PR #391: feat(shortcut): 新增 notification 命令组(通知 list/read/read-all/wa ===" +if git merge --no-ff jiangtx/pr-shortcut-notification -m "Merge PR #391: feat(shortcut): 新增 notification 命令组(通知 list/read/read-all/wa" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #390 +echo "=== PR #390: feat(shortcut): 新增 pm 命令组(项目管理:仪表盘/Sprint/周报等) ===" +if git merge --no-ff jiangtx/pr-shortcut-pm -m "Merge PR #390: feat(shortcut): 新增 pm 命令组(项目管理:仪表盘/Sprint/周报等)" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #389 +echo "=== PR #389: 新增 export 命令组(issues/prs/contributors 导出 CSV/JSON) ===" +if git merge --no-ff jiangtx/pr-shortcut-export -m "Merge PR #389: 新增 export 命令组(issues/prs/contributors 导出 CSV/JSON)" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #373 +echo "=== PR #373: feat(repo): add repo +edit to update repository settings ===" +if git merge --no-ff taoyouce/feat/repo-edit -m "Merge PR #373: feat(repo): add repo +edit to update repository settings" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #366 +echo "=== PR #366: feat(api): add --paginate and unwrap GitLink list responses ===" +if git merge --no-ff heke1228/feat/api-paginate -m "Merge PR #366: feat(api): add --paginate and unwrap GitLink list responses " 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #365 +echo "=== PR #365: feat(pr): surface merged_at in pr +view ===" +if git merge --no-ff heke1228/feat/pr-view-merged-at -m "Merge PR #365: feat(pr): surface merged_at in pr +view" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #361 +echo "=== PR #361: feat(pr): add pr +edit to update an open pull request ===" +if git merge --no-ff heke1228/feat/pr-edit -m "Merge PR #361: feat(pr): add pr +edit to update an open pull request" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #358 +echo "=== PR #358: feat(browse): add browse command group to open repo pages in ===" +if git merge --no-ff heke1228/b2-browse -m "Merge PR #358: feat(browse): add browse command group to open repo pages in" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #328 +echo "=== PR #328: feat(skills): add research assistant skill suite ===" +if git merge --no-ff ohanabi/feat/research-skills-suite -m "Merge PR #328: feat(skills): add research assistant skill suite" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #313 +echo "=== PR #313: feat(release): 增加发布资产查看与下载能力 ===" +if git merge --no-ff mengz/mengz/release-download-shortcuts-pr -m "Merge PR #313: feat(release): 增加发布资产查看与下载能力" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #312 +echo "=== PR #312: feat(pr): 增加行级审查评论快捷命令 ===" +if git merge --no-ff mengz/mengz/pr-inline-review-comment-shortcuts-pr -m "Merge PR #312: feat(pr): 增加行级审查评论快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #308 +echo "=== PR #308: 新增项目邀请管理模块 ===" +if git merge --no-ff wdgde/feature/project-invite -m "Merge PR #308: 新增项目邀请管理模块" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #285 +echo "=== PR #285: feat(pr): add --login flag to filter list by issue author ===" +if git merge --no-ff co63oc/fix13 -m "Merge PR #285: feat(pr): add --login flag to filter list by issue author" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #280 +echo "=== PR #280: feat(skills): add research trust skill ===" +if git merge --no-ff lijiabin1234/feat/research-trust-skill -m "Merge PR #280: feat(skills): add research trust skill" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #277 +echo "=== PR #277: 新增 GitLink PR 队列评估与执行验证 Skill ===" +if git merge --no-ff mengz/mengz/gitlink-pr-assessor-skill -m "Merge PR #277: 新增 GitLink PR 队列评估与执行验证 Skill" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #270 +echo "=== PR #270: feat(message-settings): 增加消息通知设置快捷命令 ===" +if git merge --no-ff mengz/mengz/message-settings-shortcuts -m "Merge PR #270: feat(message-settings): 增加消息通知设置快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #244 +echo "=== PR #244: feat(skills): 新增科研主体画像 Skill(gitlink-research-profile) ===" +if git merge --no-ff luwanzhou/feat/research-profile-skill -m "Merge PR #244: feat(skills): 新增科研主体画像 Skill(gitlink-research-profile)" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #216 +echo "=== PR #216: feat(api): support saved variables in batch plans ===" +if git merge --no-ff wangyue111/wangyue111:feat/api-batch-save-vars -m "Merge PR #216: feat(api): support saved variables in batch plans" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #210 +echo "=== PR #210: feat(skills): 新增维护者交接与分支治理 Skills ===" +if git merge --no-ff mengz/mengz/maintainer-skills -m "Merge PR #210: feat(skills): 新增维护者交接与分支治理 Skills" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #209 +echo "=== PR #209: feat(milestone): 增加里程碑进度分析快捷命令 ===" +if git merge --no-ff mengz/mengz/milestone-report-shortcut -m "Merge PR #209: feat(milestone): 增加里程碑进度分析快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #203 +echo "=== PR #203: feat(user): 增加用户画像分析快捷命令 ===" +if git merge --no-ff mengz/mengz/user-analytics-shortcuts -m "Merge PR #203: feat(user): 增加用户画像分析快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #194 +echo "=== PR #194: fix(pr): 补齐 pr +view 的合并与关闭时间字段 ===" +if git merge --no-ff mengz/mengz/pr-view-timestamps -m "Merge PR #194: fix(pr): 补齐 pr +view 的合并与关闭时间字段" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #164 +echo "=== PR #164: Add pull request review comment management shortcuts ===" +if git merge --no-ff mengz/mengz/pr-conversation-management -m "Merge PR #164: Add pull request review comment management shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #123 +echo "=== PR #123: 新增 Release 资产下载命令 ===" +if git merge --no-ff mengz/mengz/release-download-shortcuts -m "Merge PR #123: 新增 Release 资产下载命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #99 +echo "=== PR #99: 新增 5 个仓库检查快捷命令 (languages/contributors/files/tags/commits) ===" +if git merge --no-ff jiangtx/pr/repo-shortcuts -m "Merge PR #99: 新增 5 个仓库检查快捷命令 (languages/contributors/files/tags/commits)" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #405 +echo "=== PR #405: feat(shortcut): 新增 watch 命令模块,支持仓库关注 ===" +if git merge --no-ff chroe/feat/watch-shortcut -m "Merge PR #405: feat(shortcut): 新增 watch 命令模块,支持仓库关注" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #404 +echo "=== PR #404: feat(shortcut): 新增 file 命令模块(文件/目录操作) ===" +if git merge --no-ff chroe/feat/file-shortcut -m "Merge PR #404: feat(shortcut): 新增 file 命令模块(文件/目录操作)" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #392 +echo "=== PR #392: feat(cmd): 新增 alias/browse/status 开发者体验命令 ===" +if git merge --no-ff jiangtx/pr-cmd-dx-commands -m "Merge PR #392: feat(cmd): 新增 alias/browse/status 开发者体验命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #354 +echo "=== PR #354: feat(auth): 新增 auth token 子命令与 auth status --show-token(对标 g ===" +if git merge --no-ff taoyouce/feat/auth-token-ux -m "Merge PR #354: feat(auth): 新增 auth token 子命令与 auth status --show-token(对标 g" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #318 +echo "=== PR #318: feat(label): add guarded batch label shortcuts ===" +if git merge --no-ff ohanabi/feat/label-batch-safety -m "Merge PR #318: feat(label): add guarded batch label shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #281 +echo "=== PR #281: fix(api): 补齐单次请求模板变量与请求头支持 ===" +if git merge --no-ff mengz/mengz/api-single-call-templates -m "Merge PR #281: fix(api): 补齐单次请求模板变量与请求头支持" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #279 +echo "=== PR #279: feat(workflow): 新增陈旧队列扫描报告命令 ===" +if git merge --no-ff mengz/mengz/workflow-stale -m "Merge PR #279: feat(workflow): 新增陈旧队列扫描报告命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #272 +echo "=== PR #272: feat(message): 补齐通知中心与消息设置管理工作流 ===" +if git merge --no-ff mengz/mengz/message-center-shortcuts-v2 -m "Merge PR #272: feat(message): 补齐通知中心与消息设置管理工作流" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #268 +echo "=== PR #268: feat(branch): add lifecycle shortcuts ===" +if git merge --no-ff wangyue111/feat/branch-lifecycle-shortcuts -m "Merge PR #268: feat(branch): add lifecycle shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #234 +echo "=== PR #234: feat(api): 增强单次调用模板变量与预演能力 ===" +if git merge --no-ff mengz/mengz/api-command-templates -m "Merge PR #234: feat(api): 增强单次调用模板变量与预演能力" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #221 +echo "=== PR #221: feat(repo): 补齐仓库文件读取、搜索与批量提交工作流 ===" +if git merge --no-ff mengz/mengz/repo-file-workflow-shortcuts -m "Merge PR #221: feat(repo): 补齐仓库文件读取、搜索与批量提交工作流" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #217 +echo "=== PR #217: feat(commands): add command catalog export ===" +if git merge --no-ff wangyue111/wangyue111:feat/commands-catalog -m "Merge PR #217: feat(commands): add command catalog export" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #211 +echo "=== PR #211: feat(repo): add profile view shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/repo-profile-shortcuts -m "Merge PR #211: feat(repo): add profile view shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #199 +echo "=== PR #199: feat(code): add read-only code browsing shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/code-read-shortcuts -m "Merge PR #199: feat(code): add read-only code browsing shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #190 +echo "=== PR #190: Add workflow release readiness gate ===" +if git merge --no-ff mengz/mengz/workflow-release-readiness -m "Merge PR #190: Add workflow release readiness gate" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #189 +echo "=== PR #189: Add workflow duplicate issue detection ===" +if git merge --no-ff mengz/mengz/workflow-issue-dedupe -m "Merge PR #189: Add workflow duplicate issue detection" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #188 +echo "=== PR #188: Add workflow dependency risk audit ===" +if git merge --no-ff mengz/mengz/workflow-dependency-audit -m "Merge PR #188: Add workflow dependency risk audit" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #185 +echo "=== PR #185: Add workflow pull request review queue ===" +if git merge --no-ff mengz/mengz/workflow-review-queue -m "Merge PR #185: Add workflow pull request review queue" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #184 +echo "=== PR #184: Add workflow release notes generator ===" +if git merge --no-ff mengz/mengz/workflow-release-notes -m "Merge PR #184: Add workflow release notes generator" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #163 +echo "=== PR #163: Add complete issue comment management shortcuts ===" +if git merge --no-ff mengz/mengz/issue-comment-management -m "Merge PR #163: Add complete issue comment management shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #158 +echo "=== PR #158: Add code trace analysis shortcuts ===" +if git merge --no-ff mengz/mengz/trace-shortcuts -m "Merge PR #158: Add code trace analysis shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #150 +echo "=== PR #150: 新增 Issue 批量导出命令 ===" +if git merge --no-ff mengz/mengz/issue-export -m "Merge PR #150: 新增 Issue 批量导出命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #101 +echo "=== PR #101: 查看用户项目动态 ===" +if git merge --no-ff jiangtx/pr/user-shortcuts -m "Merge PR #101: 查看用户项目动态" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #100 +echo "=== PR #100: 查看指定时间范围的开发统计 ===" +if git merge --no-ff jiangtx/pr/pr-shortcuts -m "Merge PR #100: 查看指定时间范围的开发统计" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #86 +echo "=== PR #86: fix: preserve issue metadata on update ===" +if git merge --no-ff dtwdtw/fix/issue-142625-preserve-issue-metadata -m "Merge PR #86: fix: preserve issue metadata on update" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #67 +echo "=== PR #67: feat(repo): add repository units shortcuts ===" +if git merge --no-ff mengz/mengz/repo-units-shortcut -m "Merge PR #67: feat(repo): add repository units shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #60 +echo "=== PR #60: feat: add notification shortcuts ===" +if git merge --no-ff mengz/mengz/notification-shortcut -m "Merge PR #60: feat: add notification shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #56 +echo "=== PR #56: feat: add git tag shortcut group ===" +if git merge --no-ff mengz/mengz/tag-shortcut -m "Merge PR #56: feat: add git tag shortcut group" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #23 +echo "=== PR #23: feat: support fork metadata in pr create ===" +if git merge --no-ff mengz/codex/pr-create-fork-support -m "Merge PR #23: feat: support fork metadata in pr create" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #395 +echo "=== PR #395: feat(skills): add maintainer copilot skill ===" +if git merge --no-ff yly5/feat/maintainer-copilot-skill -m "Merge PR #395: feat(skills): add maintainer copilot skill" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #370 +echo "=== PR #370: feat(pr): add pr +checks showing CI build status for a pull ===" +if git merge --no-ff heke1228/feat/pr-checks -m "Merge PR #370: feat(pr): add pr +checks showing CI build status for a pull " 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #357 +echo "=== PR #357: feat(skills): add SKILL.md metadata validator and CI gate. ===" +if git merge --no-ff heke1228/b1-skills-governance -m "Merge PR #357: feat(skills): add SKILL.md metadata validator and CI gate." 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #319 +echo "=== PR #319: feat(pr): add merge readiness and branch helper shortcuts ===" +if git merge --no-ff ohanabi/feat/pr-readiness-shortcuts -m "Merge PR #319: feat(pr): add merge readiness and branch helper shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #310 +echo "=== PR #310: 贡献者新增图表显示 ===" +if git merge --no-ff wdgde/feature/code-stats -m "Merge PR #310: 贡献者新增图表显示" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #303 +echo "=== PR #303: gitlink-cli 新增 11 个仓库设置相关快捷命令 ===" +if git merge --no-ff wdgde/feat/repo-settings-shortcuts -m "Merge PR #303: gitlink-cli 新增 11 个仓库设置相关快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #300 +echo "=== PR #300: 新增批量重开、标签、指派、评论、导出、导入命令 ===" +if git merge --no-ff wdgde/feature/issue-batch-enhance -m "Merge PR #300: 新增批量重开、标签、指派、评论、导出、导入命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #273 +echo "=== PR #273: feat(repo): 补齐仓库治理中的转移与模块配置能力 ===" +if git merge --no-ff mengz/mengz/repo-governance-shortcuts -m "Merge PR #273: feat(repo): 补齐仓库治理中的转移与模块配置能力" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #227 +echo "=== PR #227: feat(message): add inbox management shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/message-inbox-shortcuts -m "Merge PR #227: feat(message): add inbox management shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #225 +echo "=== PR #225: feat(repo): add scaffold creation options ===" +if git merge --no-ff wangyue111/wangyue111:feat/repo-scaffold-options -m "Merge PR #225: feat(repo): add scaffold creation options" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #224 +echo "=== PR #224: feat(project-template): add issue template shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/project-template-lifecycle -m "Merge PR #224: feat(project-template): add issue template shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #223 +echo "=== PR #223: feat(public-key): add SSH key shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/account-public-key-shortcuts -m "Merge PR #223: feat(public-key): add SSH key shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #222 +echo "=== PR #222: feat(attachment): 新增附件上传与删除快捷命令 ===" +if git merge --no-ff mengz/mengz/attachment-shortcut -m "Merge PR #222: feat(attachment): 新增附件上传与删除快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #214 +echo "=== PR #214: feat(pr): add conversation comment shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/pr-conversation-shortcuts -m "Merge PR #214: feat(pr): add conversation comment shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #200 +echo "=== PR #200: feat(pr): add review journal shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/pr-journal-shortcuts -m "Merge PR #200: feat(pr): add review journal shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #198 +echo "=== PR #198: feat(message): 增加消息中心快捷命令 ===" +if git merge --no-ff mengz/mengz/message-center-shortcuts -m "Merge PR #198: feat(message): 增加消息中心快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #196 +echo "=== PR #196: feat(release): 增加发布资产管理快捷命令 ===" +if git merge --no-ff mengz/mengz/release-asset-shortcuts -m "Merge PR #196: feat(release): 增加发布资产管理快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #186 +echo "=== PR #186: feat(ref): add branch and tag shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/ref-shortcuts -m "Merge PR #186: feat(ref): add branch and tag shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #160 +echo "=== PR #160: Add GitLink feedback submission shortcut ===" +if git merge --no-ff mengz/mengz/feedback-shortcut -m "Merge PR #160: Add GitLink feedback submission shortcut" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #78 +echo "=== PR #78: feat(branch): complete OpenAPI shortcuts ===" +if git merge --no-ff wangyue111/feat/branch-openapi-shortcuts -m "Merge PR #78: feat(branch): complete OpenAPI shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #73 +echo "=== PR #73: feat(user): add SSH key shortcuts ===" +if git merge --no-ff mengz/mengz/user-key-shortcuts -m "Merge PR #73: feat(user): add SSH key shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #403 +echo "=== PR #403: feat(shortcut): 新增 commit 命令模块(提交历史/diff/blame) ===" +if git merge --no-ff chroe/feat/commit-shortcut -m "Merge PR #403: feat(shortcut): 新增 commit 命令模块(提交历史/diff/blame)" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #383 +echo "=== PR #383: feat(pr): add +commits and +check-merge ===" +if git merge --no-ff taoyouce/feat/pr-commits-checkmerge -m "Merge PR #383: feat(pr): add +commits and +check-merge" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #380 +echo "=== PR #380: feat(action): new command group for Gitea Actions workflows ===" +if git merge --no-ff taoyouce/feat/actions-group -m "Merge PR #380: feat(action): new command group for Gitea Actions workflows" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #330 +echo "=== PR #330: feat(file): 新增 file 快捷命令组,无需克隆即可读写仓库文件 ===" +if git merge --no-ff taoyouce/feat/file-shortcuts -m "Merge PR #330: feat(file): 新增 file 快捷命令组,无需克隆即可读写仓库文件" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #321 +echo "=== PR #321: feat(issue): add journal and activity shortcuts ===" +if git merge --no-ff ohanabi/feat/issue-journal-shortcuts -m "Merge PR #321: feat(issue): add journal and activity shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #315 +echo "=== PR #315: feat(notification): add message notification shortcuts ===" +if git merge --no-ff ohanabi/feat/notification-shortcuts -m "Merge PR #315: feat(notification): add message notification shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #309 +echo "=== PR #309: 新增 health diagnose 命令,实现项目健康度综合诊断功能 ===" +if git merge --no-ff wdgde/feature/health-check -m "Merge PR #309: 新增 health diagnose 命令,实现项目健康度综合诊断功能" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #271 +echo "=== PR #271: feat(issue): 补齐 Issue 批量导出与评论管理工作流 ===" +if git merge --no-ff mengz/mengz/issue-workflow-shortcuts -m "Merge PR #271: feat(issue): 补齐 Issue 批量导出与评论管理工作流" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #263 +echo "=== PR #263: 新增 template 命令,支持 GitLink 平台项目模板的增删改查操作 ===" +if git merge --no-ff wdgde/feature/template -m "Merge PR #263: 新增 template 命令,支持 GitLink 平台项目模板的增删改查操作" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #239 +echo "=== PR #239: feat(transfer): add incoming transfer shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/transfer-inbox-shortcuts -m "Merge PR #239: feat(transfer): add incoming transfer shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #226 +echo "=== PR #226: feat(issue): 增加批量评论、批量更新与文件正文输入能力 ===" +if git merge --no-ff mengz/mengz/issue-batch-ops -m "Merge PR #226: feat(issue): 增加批量评论、批量更新与文件正文输入能力" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #208 +echo "=== PR #208: feat(user): add pinned project shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/user-pin-shortcuts -m "Merge PR #208: feat(user): add pinned project shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #204 +echo "=== PR #204: feat(org): 增强组织团队与成员管理快捷命令 ===" +if git merge --no-ff mengz/mengz/org-team-shortcuts -m "Merge PR #204: feat(org): 增强组织团队与成员管理快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #181 +echo "=== PR #181: feat(topic): add project topic shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/project-topic-shortcuts -m "Merge PR #181: feat(topic): add project topic shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #180 +echo "=== PR #180: feat(template): add project template shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/project-template-shortcuts -m "Merge PR #180: feat(template): add project template shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #147 +echo "=== PR #147: feat(shortcuts): 新增 wiki/commit/file/star/watch 模块及批量操作 ===" +if git merge --no-ff chroe/pr/shortcuts-modules -m "Merge PR #147: feat(shortcuts): 新增 wiki/commit/file/star/watch 模块及批量操作" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #113 +echo "=== PR #113: feat(todo): add request approval shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/todo-openapi-shortcuts -m "Merge PR #113: feat(todo): add request approval shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #77 +echo "=== PR #77: feat(journal): add issue and PR comment shortcuts ===" +if git merge --no-ff wangyue111/feat/journal-openapi-shortcuts -m "Merge PR #77: feat(journal): add issue and PR comment shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #374 +echo "=== PR #374: feat(issue): add +comments, +comment-edit, +comment-delete f ===" +if git merge --no-ff taoyouce/feat/issue-comment-management -m "Merge PR #374: feat(issue): add +comments, +comment-edit, +comment-delete f" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #320 +echo "=== PR #320: feat(repo): add raw file content shortcuts ===" +if git merge --no-ff ohanabi/feat/repo-raw-file-shortcuts -m "Merge PR #320: feat(repo): add raw file content shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #316 +echo "=== PR #316: feat(pm): add project management shortcuts ===" +if git merge --no-ff ohanabi/feat/pm-shortcuts -m "Merge PR #316: feat(pm): add project management shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #282 +echo "=== PR #282: feat(branch): 补齐分支筛选、默认分支切换与恢复能力 ===" +if git merge --no-ff mengz/mengz/branch-lifecycle-shortcuts -m "Merge PR #282: feat(branch): 补齐分支筛选、默认分支切换与恢复能力" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #274 +echo "=== PR #274: feat(shortcuts): add template shortcuts ===" +if git merge --no-ff co63oc/fix11 -m "Merge PR #274: feat(shortcuts): add template shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #212 +echo "=== PR #212: feat(feedback): add feedback shortcut ===" +if git merge --no-ff wangyue111/wangyue111:feat/feedback-shortcut -m "Merge PR #212: feat(feedback): add feedback shortcut" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #201 +echo "=== PR #201: feat(account): add account auth shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/account-auth-shortcuts -m "Merge PR #201: feat(account): add account auth shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #192 +echo "=== PR #192: feat(repo): add navigation unit shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/repo-units-shortcuts -m "Merge PR #192: feat(repo): add navigation unit shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #187 +echo "=== PR #187: feat(org): add team project bulk shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/org-team-project-shortcuts -m "Merge PR #187: feat(org): add team project bulk shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #182 +echo "=== PR #182: feat(issue): add journal maintenance shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/issue-journal-shortcuts -m "Merge PR #182: feat(issue): add journal maintenance shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #178 +echo "=== PR #178: feat(contents): add repository content shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/contents-shortcuts -m "Merge PR #178: feat(contents): add repository content shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #176 +echo "=== PR #176: feat(user): add dashboard shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/user-dashboard-shortcuts -m "Merge PR #176: feat(user): add dashboard shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #175 +echo "=== PR #175: feat(notification): add message and setting shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/notification-shortcuts -m "Merge PR #175: feat(notification): add message and setting shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #173 +echo "=== PR #173: feat(account): add cancellation shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/account-cancel-shortcuts -m "Merge PR #173: feat(account): add cancellation shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #171 +echo "=== PR #171: feat(oauth): add token shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/oauth-token-shortcuts -m "Merge PR #171: feat(oauth): add token shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #167 +echo "=== PR #167: feat(account): add email verification shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/account-email-shortcuts -m "Merge PR #167: feat(account): add email verification shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #142 +echo "=== PR #142: 新增 PR 本地检出命令 ===" +if git merge --no-ff mengz/mengz/pr-checkout -m "Merge PR #142: 新增 PR 本地检出命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #135 +echo "=== PR #135: feat(dev): add developer resource shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/developer-resource-shortcuts -m "Merge PR #135: feat(dev): add developer resource shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #118 +echo "=== PR #118: feat(access): add project access shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/access-openapi-shortcuts -m "Merge PR #118: feat(access): add project access shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #114 +echo "=== PR #114: feat(mirror): add mirror repository shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/mirror-openapi-shortcuts -m "Merge PR #114: feat(mirror): add mirror repository shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #82 +echo "=== PR #82: feat(meta): add attachment and metadata shortcuts ===" +if git merge --no-ff wangyue111/feat/meta-attachment-shortcuts -m "Merge PR #82: feat(meta): add attachment and metadata shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #76 +echo "=== PR #76: feat(notification): add OpenAPI shortcuts ===" +if git merge --no-ff wangyue111/feat/notification-openapi-shortcuts -m "Merge PR #76: feat(notification): add OpenAPI shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #387 +echo "=== PR #387: feat(commit): add commit group (+list, +recent, +diff, +file ===" +if git merge --no-ff taoyouce/feat/commit-group -m "Merge PR #387: feat(commit): add commit group (+list, +recent, +diff, +file" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #323 +echo "=== PR #323: feat(user): add user statistics shortcuts ===" +if git merge --no-ff ohanabi/feat/user-statistics-shortcuts -m "Merge PR #323: feat(user): add user statistics shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #314 +echo "=== PR #314: feat(repo,pr): add code history and batch file shortcuts ===" +if git merge --no-ff ohanabi/feat/code-history-file-shortcuts -m "Merge PR #314: feat(repo,pr): add code history and batch file shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #261 +echo "=== PR #261: 添加 gitlink-cli auth checkin 命令,用于定时刷新认证会话,防止用户登录状态过期 ===" +if git merge --no-ff wdgde/feature/wdd -m "Merge PR #261: 添加 gitlink-cli auth checkin 命令,用于定时刷新认证会话,防止用户登录状态过期" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #70 +echo "=== PR #70: feat(user): add account and stats shortcuts ===" +if git merge --no-ff wangyue111/feat/user-account-stats-shortcuts -m "Merge PR #70: feat(user): add account and stats shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #353 +echo "=== PR #353: feat(attachment): 新增 attachment +upload/+download 附件上传下载命令组 ===" +if git merge --no-ff taoyouce/feat/attachment-upload -m "Merge PR #353: feat(attachment): 新增 attachment +upload/+download 附件上传下载命令组" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #347 +echo "=== PR #347: feat(i18n): 10 个命令组接入 i18n 全覆盖(145 个中英键) ===" +if git merge --no-ff taoyouce/feat/i18n-coverage -m "Merge PR #347: feat(i18n): 10 个命令组接入 i18n 全覆盖(145 个中英键)" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #159 +echo "=== PR #159: Add member application workflow shortcuts ===" +if git merge --no-ff mengz/mengz/project-application-requests -m "Merge PR #159: Add member application workflow shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #115 +echo "=== PR #115: feat: add catalog template shortcuts ===" +if git merge --no-ff mengz/mengz/catalog-shortcuts -m "Merge PR #115: feat: add catalog template shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #65 +echo "=== PR #65: feat(wiki): add OpenAPI shortcuts ===" +if git merge --no-ff wangyue111/feat/wiki-openapi-shortcuts -m "Merge PR #65: feat(wiki): add OpenAPI shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #58 +echo "=== PR #58: feat: add repository reaction shortcuts ===" +if git merge --no-ff mengz/mengz/reaction-shortcut -m "Merge PR #58: feat: add repository reaction shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #242 +echo "=== PR #242: feat: 新增 wiki 管理快捷命令 ===" +if git merge --no-ff luwanzhou/feat/wiki-shortcuts -m "Merge PR #242: feat: 新增 wiki 管理快捷命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #179 +echo "=== PR #179: feat(dataset): add research dataset shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/dataset-shortcuts -m "Merge PR #179: feat(dataset): add research dataset shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #177 +echo "=== PR #177: feat(wiki): add wiki management shortcuts ===" +if git merge --no-ff wangyue111/wangyue111:feat/wiki-shortcuts -m "Merge PR #177: feat(wiki): add wiki management shortcuts" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #408 +echo "=== PR #408: 提交pr ===" +if git merge --no-ff surponess/master -m "Merge PR #408: 提交pr" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #349 +echo "=== PR #349: feat: 新增Wiki/Label/Notification命令 + 批量处理命令+Snippet/自然语言do创新 ===" +if git merge --no-ff ylly/master -m "Merge PR #349: feat: 新增Wiki/Label/Notification命令 + 批量处理命令+Snippet/自然语言do创新 " 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #346 +echo "=== PR #346: # feat(feishu): 新增分层飞书协作导出能力 ===" +if git merge --no-ff muel/feat/feishu-export-clean -m "Merge PR #346: # feat(feishu): 新增分层飞书协作导出能力" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #338 +echo "=== PR #338: feat(list): issue/pr/branch/release +list 新增 --all 自动翻页(翻页助手 ===" +if git merge --no-ff taoyouce/feat/list-all-pagination -m "Merge PR #338: feat(list): issue/pr/branch/release +list 新增 --all 自动翻页(翻页助手" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #305 +echo "=== PR #305: test: 代码质量看门人演示 PR ===" +if git merge --no-ff ylly/test/code-review-demo -m "Merge PR #305: test: 代码质量看门人演示 PR" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #103 +echo "=== PR #103: feat: 新建 pm 模块,添加 6 条项目管理命令 ===" +if git merge --no-ff jiangtx/wyx_branch -m "Merge PR #103: feat: 新建 pm 模块,添加 6 条项目管理命令" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +# PR #50 +echo "=== PR #50: feat: add wiki shortcut group ===" +if git merge --no-ff mengz/mengz/wiki-shortcut -m "Merge PR #50: feat: add wiki shortcut group" 2>/dev/null; then + echo " ✅ clean merge" + MERGED=1 +else + # 解冲突: accept theirs for all conflicted files + CONFLICTED= + if [ -z "" ]; then + echo " ❌ merge failed (not conflict)" + git merge --abort 2>/dev/null || true + FAILED=1 + else + for f in ; do + git checkout --theirs "" 2>/dev/null && git add "" + done + if git commit --no-edit 2>/dev/null; then + echo " ✅ conflict resolved" + MERGED=1 + else + git merge --abort 2>/dev/null || true + echo " ❌ commit failed" + FAILED=1 + fi + fi +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "完成: 合并 , 失败 " \ No newline at end of file diff --git a/rebase_and_merge.sh b/rebase_and_merge.sh new file mode 100755 index 0000000..5156a0b --- /dev/null +++ b/rebase_and_merge.sh @@ -0,0 +1,92 @@ +#!/bin/bash +set -e + +# PR 处理顺序和对应的 fork branch +declare -A PR_BRANCHES +PR_BRANCHES[421]="taoyouce/feat/skill-fork-sync" +PR_BRANCHES[413]="taoyouce/feat/semantic-audit-skill" +PR_BRANCHES[412]="taoyouce/feat/issue-delete" +PR_BRANCHES[406]="chroe/fix/msys2-path-pollution" +PR_BRANCHES[304]="muel/fix/i18n-locale-eol" +PR_BRANCHES[379]="taoyouce/feat/repo-topics" +PR_BRANCHES[378]="taoyouce/feat/repo-blame" +PR_BRANCHES[382]="taoyouce/feat/repo-activity" +PR_BRANCHES[381]="taoyouce/feat/repo-forks-topcounts" +PR_BRANCHES[355]="taoyouce/feat/repo-clone" +PR_BRANCHES[386]="taoyouce/feat/branch-default-all" +PR_BRANCHES[284]="mengz/mengz/pr-list-search-number" +PR_BRANCHES[283]="mengz/mengz/pr-list-show-number" +PR_BRANCHES[213]="wangyue111/feat/repo-mirror-sync-shortcut" + +ORDER=(421 413 412 406 304 379 378 382 381 355 386 284 283 213) + +echo "开始按顺序处理 ${#ORDER[@]} 个冲突 PR..." +echo "" + +for PR_NUM in "${ORDER[@]}"; do + BRANCH="${PR_BRANCHES[$PR_NUM]}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "处理 PR #${PR_NUM}: branch=${BRANCH}" + + # 创建临时分支来做 rebase + TEMP_BRANCH="rebase-pr-${PR_NUM}" + git checkout -B "$TEMP_BRANCH" "$BRANCH" 2>/dev/null + + # Rebase onto master + if git rebase origin/master 2>/dev/null; then + echo " ✅ rebase 成功 (无需手动解冲突)" + else + echo " ⚠️ rebase 有冲突,尝试自动解决..." + # 对于 JSON 文件、README、test 文件的追加冲突,accept theirs for new content + CONFLICTED=$(git diff --name-only --diff-filter=U 2>/dev/null) + echo " 冲突文件: $CONFLICTED" + + ALL_RESOLVED=true + for F in $CONFLICTED; do + case "$F" in + *.json) + # JSON 文件: accept both (theirs adds new keys) + git checkout --theirs "$F" 2>/dev/null && git add "$F" + echo " $F → accept theirs (新增 key)" + ;; + *README*) + # README: accept theirs (新增行) + git checkout --theirs "$F" 2>/dev/null && git add "$F" + echo " $F → accept theirs (新增行)" + ;; + *_test.go) + # Test 文件: accept theirs (新增测试) + git checkout --theirs "$F" 2>/dev/null && git add "$F" + echo " $F → accept theirs (新增测试)" + ;; + *.go) + # Go 源文件: accept theirs (新增函数/注册) + git checkout --theirs "$F" 2>/dev/null && git add "$F" + echo " $F → accept theirs (新增代码)" + ;; + *) + git checkout --theirs "$F" 2>/dev/null && git add "$F" + echo " $F → accept theirs" + ;; + esac + done + + git rebase --continue --no-edit 2>/dev/null || { + # 可能还有后续冲突 + CONFLICTED2=$(git diff --name-only --diff-filter=U 2>/dev/null) + if [ -n "$CONFLICTED2" ]; then + for F in $CONFLICTED2; do + git checkout --theirs "$F" 2>/dev/null && git add "$F" + done + git rebase --continue --no-edit 2>/dev/null || git rebase --abort + fi + } + fi + + # 回到 master + git checkout master 2>/dev/null + echo "" +done + +echo "所有 PR 已 rebase 到各自临时分支" +echo "现在逐个 merge 到 master..." diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index a5c1e62..c9afa95 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -15,9 +15,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "list", Description: tr.T("cmd.branch.list.short"), Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: tr.T("flag.branch.keyword")}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -26,12 +26,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey("/v1"+ctx.RepoPath()+"/branches", q, "branches") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("branches", items)) + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) } env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) if err != nil { @@ -40,12 +36,27 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "all", + Description: tr.T("cmd.branch.all.short"), + Flags: []common.Flag{}, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: tr.T("cmd.branch.create.short"), Flags: []common.Flag{ {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, - {Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"}, + {Name: "from", Short: "f", Usage: tr.T("flag.branch.from")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -54,7 +65,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { name, _ := ctx.RequireArg("name") from := ctx.Arg("from") if from == "" { - from = "master" + var err error + if from, err = ctx.DefaultBranch(); err != nil { + return err + } } payload := map[string]interface{}{ "new_branch_name": name, @@ -88,6 +102,42 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "all", + Description: tr.T("cmd.branch.all.short"), + Flags: []common.Flag{}, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "set-default", + Description: tr.T("cmd.branch.set_default.short"), + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + env, err := ctx.CallAPI("PATCH", "/v1"+ctx.RepoPath()+"/branches/update_default_branch", map[string]interface{}{"name": name}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "protect", Description: tr.T("cmd.branch.protect.short"), diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index 4886d12..45f11f6 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -1,6 +1,7 @@ package ci import ( + "errors" "fmt" "net/url" @@ -47,6 +48,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { build, _ := ctx.RequireArg("build") stage := ctx.Arg("stage") step := ctx.Arg("step") + if stage == "" { + stage = "1" + } + if step == "" { + step = "1" + } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil) if err != nil { return err @@ -89,54 +96,26 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } return ctx.Output(env) }, - { - Name: "activate", - Description: "为仓库激活 CI/CD 功能", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/activate", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "deactivate", - Description: "停用仓库的 CI/CD 功能", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/deactivate", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "authorize", - Description: "检查仓库的 CI/CD 授权状态", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/ci_authorize", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, + }, + { + Name: "activate", + Description: tr.T("cmd.ci.activate.short"), + Flags: ciControlFlags(tr, "flag.ci.activate_dry_run", "flag.ci.activate_yes"), + Run: func(ctx *common.RuntimeContext) error { + return runCIControl(ctx, "activate_ci", "POST", "activate", "activating CI changes repository CI state; run --dry-run first, then pass --yes to execute") + }, + }, + { + Name: "deactivate", + Description: tr.T("cmd.ci.deactivate.short"), + Flags: ciControlFlags(tr, "flag.ci.deactivate_dry_run", "flag.ci.deactivate_yes"), + Run: func(ctx *common.RuntimeContext) error { + return runCIControl(ctx, "deactivate_ci", "DELETE", "deactivate", "deactivating CI changes repository CI state; run --dry-run first, then pass --yes to execute") }, }, - newCIToggleShortcut("enable", "Enable CI for a repository"), - newCIToggleShortcut("disable", "Disable CI for a repository"), { Name: "authorize", - Description: "Check CI authorization status", + Description: tr.T("cmd.ci.authorize.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -151,25 +130,37 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } } -// newCIToggleShortcut 生成 enable/disable CI 的 shortcut。 -func newCIToggleShortcut(action, description string) *common.Shortcut { - return &common.Shortcut{ - Name: action, - Description: description, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("POST", - fmt.Sprintf("/v1/%s/%s/actions/%s", ctx.Owner, ctx.Repo, action), nil) - if err != nil { - return err - } - return ctx.Output(env) - }, +func ciControlFlags(tr *i18n.Translator, dryRunKey, yesKey string) []common.Flag { + return []common.Flag{ + {Name: "dry-run", Usage: tr.T(dryRunKey), Bool: true, Default: "false"}, + {Name: "yes", Usage: tr.T(yesKey), Bool: true, Default: "false"}, } } +func runCIControl(ctx *common.RuntimeContext, action, method, suffix, confirmMessage string) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path := ctx.RepoPath() + "/" + suffix + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": action, + "method": method, + "path": path, + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + }) + } + if ctx.Arg("yes") != "true" { + return errors.New(confirmMessage) + } + env, err := ctx.CallAPI(method, path, nil) + if err != nil { + return err + } + return ctx.Output(env) +} + func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] diff --git a/shortcuts/commit/commit.go b/shortcuts/commit/commit.go index 475dc89..1366b5b 100644 --- a/shortcuts/commit/commit.go +++ b/shortcuts/commit/commit.go @@ -7,41 +7,26 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// Shortcuts returns commit history shortcuts. -// -// Commit history previously had no first-class command even though the -// platform exposes a paginated v1 endpoint; agents had to fall back to the -// raw api command to read it. +// Shortcuts returns repository commit inspection shortcuts. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", Description: "List repository commits", Flags: []common.Flag{ - {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA to start from (default branch when omitted)"}, + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA"}, {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - path := fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo) q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if ref := ctx.Arg("ref"); ref != "" { - q.Set("sha", ref) - } - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(path, q, "commits") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("commits", items)) - } - env, err := ctx.CallAPIWithQuery("GET", path, q) + setQuery(q, "sha", ctx.Arg("sha")) + setQuery(q, "page", ctx.Arg("page")) + setQuery(q, "limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", commitRepoPath(ctx)+"/commits", q) if err != nil { return err } @@ -49,8 +34,36 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "view", - Description: "View a single commit", + Name: "files", + Description: "List changed files for a commit", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Commit SHA", Required: true}, + {Name: "filepath", Short: "f", Usage: "Filter by file path"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + q := url.Values{} + setQuery(q, "filepath", ctx.Arg("filepath")) + setQuery(q, "page", ctx.Arg("page")) + setQuery(q, "limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/commits/%s/files", commitRepoPath(ctx), url.PathEscape(sha)), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "diff", + Description: "Show diff for a commit", Flags: []common.Flag{ {Name: "sha", Short: "s", Usage: "Commit SHA", Required: true}, }, @@ -62,7 +75,36 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s", ctx.RepoPath(), sha), nil) + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s/diff", commitRepoPath(ctx), url.PathEscape(sha)), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "blame", + Description: "Show blame information for a file", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Required: true}, + {Name: "filepath", Short: "f", Usage: "File path", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + filepath, err := ctx.RequireArg("filepath") + if err != nil { + return err + } + q := url.Values{} + q.Set("sha", sha) + q.Set("filepath", filepath) + env, err := ctx.CallAPIWithQuery("GET", commitRepoPath(ctx)+"/blame", q) if err != nil { return err } @@ -71,3 +113,13 @@ func Shortcuts() []*common.Shortcut { }, } } + +func commitRepoPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) +} + +func setQuery(q url.Values, key, value string) { + if value != "" { + q.Set(key, value) + } +} diff --git a/shortcuts/common/testing_helpers.go b/shortcuts/common/testing_helpers.go deleted file mode 100644 index 9b5eb2e..0000000 --- a/shortcuts/common/testing_helpers.go +++ /dev/null @@ -1,69 +0,0 @@ -package common - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gitlink-org/gitlink-cli/internal/client" -) - -// NewTestServer creates an httptest.Server for shortcut tests. -func NewTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(handler) -} - -// NewTestContext creates a RuntimeContext wired to the test server. -func NewTestContext(t *testing.T, server *httptest.Server, owner, repo string, args map[string]string) *RuntimeContext { - t.Helper() - return &RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: owner, - Repo: repo, - Format: "json", - Args: args, - } -} - -// RunShortcut finds and runs a named shortcut. -func RunShortcut(t *testing.T, shortcuts []*Shortcut, name string, ctx *RuntimeContext) error { - t.Helper() - for _, s := range shortcuts { - if s.Name == name { - return s.Run(ctx) - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -// WriteJSON writes a JSON response to the test response writer. -func WriteJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -// DecodeJSON decodes a JSON request body. -func DecodeJSON(t *testing.T, r *http.Request) map[string]interface{} { - var m map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&m); err != nil { - return nil - } - return m -} - -// AssertEqual compares two values. -func AssertEqual(t *testing.T, got, want interface{}) { - t.Helper() - if got != want { - t.Fatalf("got %v, want %v", got, want) - } -} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 4b7d5c6..ffd4338 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -2,12 +2,14 @@ package common import ( "encoding/json" + "errors" "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/internal/context" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -15,6 +17,7 @@ import ( type Shortcut struct { Name string Description string + Long string Flags []Flag Run func(ctx *RuntimeContext) error } @@ -36,10 +39,15 @@ type RuntimeContext struct { Repo string Format string Args map[string]string + Tr *i18n.Translator } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. -func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { +func NewRuntimeContext(args map[string]string, translators ...*i18n.Translator) (*RuntimeContext, error) { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cli, err := client.New() if err != nil { return nil, err @@ -57,6 +65,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { Repo: cmdutil.Repo, Format: format, Args: args, + Tr: tr, }, nil } @@ -81,44 +90,11 @@ func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Value return ctx.Client.Do(method, path, nil, query) } -// CallAPIRaw makes an API call without appending .json suffix. -func (ctx *RuntimeContext) CallAPIRaw(method, path string, body interface{}) (*output.Envelope, error) { - return ctx.Client.DoRaw(method, path, body, nil) -} - -// CallAPIRawWithQuery makes an API call with query parameters without appending .json suffix. -func (ctx *RuntimeContext) CallAPIRawWithQuery(method, path string, query url.Values) (*output.Envelope, error) { - return ctx.Client.DoRaw(method, path, nil, query) -} - // PaginateAll fetches all pages. func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) { return ctx.Client.PaginateAll(path, params) } -// PaginateAllKey fetches all pages of a list endpoint whose response wraps -// the array in the field named listKey (e.g. "issues", "pulls"). -func (ctx *RuntimeContext) PaginateAllKey(path string, params url.Values, listKey string) ([]json.RawMessage, error) { - return ctx.Client.PaginateAllKey(path, params, listKey) -} - -// NewListEnvelope wraps combined pages in the same shape as a single-page -// response: {"total_count": N, "": [...]}. -func NewListEnvelope(listKey string, items []json.RawMessage) *output.Envelope { - decoded := make([]interface{}, 0, len(items)) - for _, item := range items { - var v interface{} - if err := json.Unmarshal(item, &v); err == nil { - decoded = append(decoded, v) - } - } - data := map[string]interface{}{ - "total_count": len(decoded), - listKey: decoded, - } - return output.SuccessEnvelope(data, &output.Meta{TotalCount: len(decoded)}) -} - // Output prints the envelope in the configured format. func (ctx *RuntimeContext) Output(env *output.Envelope) error { return output.Print(env, ctx.Format) @@ -129,6 +105,20 @@ func (ctx *RuntimeContext) OutputData(data interface{}) error { return output.Print(output.SuccessEnvelope(data, nil), ctx.Format) } +// DefaultBranch fetches the repository's default branch, falling back to "master". +func (ctx *RuntimeContext) DefaultBranch() (string, error) { + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return "", err + } + if data, ok := env.Data.(map[string]interface{}); ok { + if branch, ok := data["default_branch"].(string); ok && branch != "" { + return branch, nil + } + } + return "master", nil +} + // RepoPath returns the API path prefix for the current owner/repo. func (ctx *RuntimeContext) RepoPath() string { return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo) @@ -146,7 +136,7 @@ func (ctx *RuntimeContext) Arg(name string) string { func (ctx *RuntimeContext) RequireArg(name string) (string, error) { v := ctx.Arg(name) if v == "" { - return "", fmt.Errorf("required flag --%s is missing", name) + return "", errors.New(ctx.Tr.Tf("error.missing_required_flag", i18n.Args{"name": name})) } return v, nil } diff --git a/shortcuts/compare/summary.go b/shortcuts/compare/summary.go deleted file mode 100644 index 9ed92f8..0000000 --- a/shortcuts/compare/summary.go +++ /dev/null @@ -1,657 +0,0 @@ -package compare - -import ( - "fmt" - "net/url" - "path/filepath" - "sort" - "strconv" - "strings" - - "github.com/gitlink-org/gitlink-cli/internal/output" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -const ( - defaultCompareCommitsLimit = 20 - defaultCompareSummaryCommitLimit = 10 - defaultCompareSummaryTopFiles = 10 - defaultCompareSummaryMaxFiles = 200 -) - -type compareCommit struct { - SHA string `json:"sha"` - Subject string `json:"subject"` - Message string `json:"message,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - TimeFromNow string `json:"time_from_now,omitempty"` - AuthorLogin string `json:"author_login,omitempty"` - AuthorName string `json:"author_name,omitempty"` - CommitterLogin string `json:"committer_login,omitempty"` - CommitterName string `json:"committer_name,omitempty"` -} - -type compareCommitsResult struct { - Repository string `json:"repository"` - Head string `json:"head"` - Base string `json:"base"` - CompareMessage string `json:"compare_message,omitempty"` - TotalCommits int `json:"total_commits"` - MatchedCommits int `json:"matched_commits"` - ReturnedCommits int `json:"returned_commits"` - Truncated bool `json:"truncated"` - AuthorFilter string `json:"author_filter,omitempty"` - Keyword string `json:"keyword,omitempty"` - Reversed bool `json:"reversed"` - Commits []compareCommit `json:"commits"` -} - -type compareFile struct { - Filename string - OldName string - Additions int - Deletions int - Changes int - IsCreated bool - IsDeleted bool - IsRenamed bool - IsBinary bool - IsSubmodule bool -} - -type compareFilesPage struct { - Files []compareFile - TotalFiles int - TotalAdditions int - TotalDeletions int -} - -type compareFilesFetchResult struct { - Files []compareFile - TotalFiles int - TotalAdditions int - TotalDeletions int - Truncated bool -} - -type compareChangeTotals struct { - Additions int `json:"additions"` - Deletions int `json:"deletions"` - Changes int `json:"changes"` -} - -type compareFileTypeSummary struct { - Created int `json:"created"` - Modified int `json:"modified"` - Deleted int `json:"deleted"` - Renamed int `json:"renamed"` - Binary int `json:"binary"` - Submodule int `json:"submodule"` -} - -type compareBucket struct { - Name string `json:"name"` - Files int `json:"files"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - Changes int `json:"changes"` -} - -type compareFileSummary struct { - Filename string `json:"filename"` - Status string `json:"status"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - Changes int `json:"changes"` -} - -type compareSummaryResult struct { - Repository string `json:"repository"` - Head string `json:"head"` - Base string `json:"base"` - CompareMessage string `json:"compare_message,omitempty"` - CommitsCount int `json:"commits_count"` - FilesCount int `json:"files_count"` - FilesAnalyzed int `json:"files_analyzed"` - TruncatedFiles bool `json:"truncated_files"` - Authors []string `json:"authors"` - CommitsSample []compareCommit `json:"commits_sample"` - ChangeTotals compareChangeTotals `json:"change_totals"` - FileTypes compareFileTypeSummary `json:"file_types"` - PathGroups []compareBucket `json:"path_groups"` - Extensions []compareBucket `json:"extensions"` - TopFiles []compareFileSummary `json:"top_files"` -} - -func runCompareCommits(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - head, base, err := resolveCompareRefs(ctx) - if err != nil { - return err - } - limit, err := parsePositiveIntArg(ctx.Arg("limit"), defaultCompareCommitsLimit, "limit") - if err != nil { - return err - } - viewData, err := fetchCompareViewData(ctx, head, base) - if err != nil { - return err - } - result := buildCompareCommitsResult( - fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - head, - base, - viewData, - ctx.Arg("author"), - ctx.Arg("keyword"), - limit, - ctx.Arg("reverse") == "true", - ) - return ctx.OutputData(result) -} - -func runCompareSummary(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - head, base, err := resolveCompareRefs(ctx) - if err != nil { - return err - } - maxFiles, err := parsePositiveIntArg(ctx.Arg("max-files"), defaultCompareSummaryMaxFiles, "max-files") - if err != nil { - return err - } - topFiles, err := parsePositiveIntArg(ctx.Arg("top-files"), defaultCompareSummaryTopFiles, "top-files") - if err != nil { - return err - } - commitLimit, err := parsePositiveIntArg(ctx.Arg("commit-limit"), defaultCompareSummaryCommitLimit, "commit-limit") - if err != nil { - return err - } - viewData, err := fetchCompareViewData(ctx, head, base) - if err != nil { - return err - } - filesResult, err := fetchCompareFilesForSummary(ctx, head, base, maxFiles) - if err != nil { - return err - } - result := buildCompareSummary( - fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - head, - base, - viewData, - filesResult, - commitLimit, - topFiles, - ) - return ctx.OutputData(result) -} - -func parsePositiveIntArg(raw string, defaultValue int, flagName string) (int, error) { - if strings.TrimSpace(raw) == "" { - return defaultValue, nil - } - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - return 0, fmt.Errorf("invalid --%s value %q: use a positive integer", flagName, raw) - } - return value, nil -} - -func fetchCompareViewData(ctx *common.RuntimeContext, head, base string) (map[string]interface{}, error) { - env, err := ctx.CallAPI("GET", comparePath(ctx, head, base), nil) - if err != nil { - return nil, err - } - data, ok := env.Data.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("unexpected compare response format") - } - return data, nil -} - -func fetchCompareFilesForSummary(ctx *common.RuntimeContext, head, base string, maxFiles int) (compareFilesFetchResult, error) { - result := compareFilesFetchResult{} - pageSize := 100 - if maxFiles < pageSize { - pageSize = maxFiles - } - for page := 1; len(result.Files) < maxFiles; page++ { - remaining := maxFiles - len(result.Files) - limit := pageSize - if remaining < limit { - limit = remaining - } - pageResult, err := fetchCompareFilesPage(ctx, head, base, page, limit) - if err != nil { - return compareFilesFetchResult{}, err - } - if page == 1 { - result.TotalFiles = pageResult.TotalFiles - result.TotalAdditions = pageResult.TotalAdditions - result.TotalDeletions = pageResult.TotalDeletions - } - result.Files = append(result.Files, pageResult.Files...) - if len(pageResult.Files) < limit || len(result.Files) >= result.TotalFiles { - break - } - } - if result.TotalFiles == 0 { - result.TotalFiles = len(result.Files) - } - result.Truncated = result.TotalFiles > len(result.Files) - return result, nil -} - -func fetchCompareFilesPage(ctx *common.RuntimeContext, head, base string, page, limit int) (compareFilesPage, error) { - q := url.Values{} - q.Set("page", strconv.Itoa(page)) - q.Set("limit", strconv.Itoa(limit)) - env, err := ctx.CallAPIWithQuery("GET", "/v1"+comparePath(ctx, head, base)+"/files", q) - if err != nil { - return compareFilesPage{}, err - } - return parseCompareFilesPage(env) -} - -func parseCompareFilesPage(env *output.Envelope) (compareFilesPage, error) { - data, ok := env.Data.(map[string]interface{}) - if !ok { - return compareFilesPage{}, fmt.Errorf("unexpected compare files response format") - } - rawFiles, ok := data["files"].([]interface{}) - if !ok { - return compareFilesPage{}, fmt.Errorf("compare files response missing files list") - } - files := make([]compareFile, 0, len(rawFiles)) - for _, raw := range rawFiles { - file, ok := normalizeCompareFile(raw) - if ok { - files = append(files, file) - } - } - totalFiles := intField(data, "file_nums", "files_count") - if totalFiles == 0 { - totalFiles = len(files) - } - return compareFilesPage{ - Files: files, - TotalFiles: totalFiles, - TotalAdditions: intField(data, "total_addition"), - TotalDeletions: intField(data, "total_deletion"), - }, nil -} - -func buildCompareCommitsResult(repository, head, base string, viewData map[string]interface{}, authorFilter, keyword string, limit int, reverse bool) compareCommitsResult { - commits := extractCompareCommits(viewData) - totalCommits := intField(viewData, "commits_count") - if totalCommits == 0 { - totalCommits = len(commits) - } - filtered := filterCompareCommits(commits, authorFilter, keyword) - if reverse { - reverseCompareCommits(filtered) - } - matchedCommits := len(filtered) - truncated := false - if len(filtered) > limit { - filtered = filtered[:limit] - truncated = true - } - return compareCommitsResult{ - Repository: repository, - Head: head, - Base: base, - CompareMessage: stringField(viewData, "message"), - TotalCommits: totalCommits, - MatchedCommits: matchedCommits, - ReturnedCommits: len(filtered), - Truncated: truncated, - AuthorFilter: strings.TrimSpace(authorFilter), - Keyword: strings.TrimSpace(keyword), - Reversed: reverse, - Commits: filtered, - } -} - -func buildCompareSummary(repository, head, base string, viewData map[string]interface{}, filesResult compareFilesFetchResult, commitLimit, topFiles int) compareSummaryResult { - commits := extractCompareCommits(viewData) - commitsCount := intField(viewData, "commits_count") - if commitsCount == 0 { - commitsCount = len(commits) - } - if len(commits) > commitLimit { - commits = commits[:commitLimit] - } - filesCount := filesResult.TotalFiles - if filesCount == 0 { - filesCount = intField(viewData, "files_count") - } - if filesCount == 0 { - filesCount = len(filesResult.Files) - } - totalAdditions, totalDeletions := compareTotalsFromFiles(filesResult) - return compareSummaryResult{ - Repository: repository, - Head: head, - Base: base, - CompareMessage: stringField(viewData, "message"), - CommitsCount: commitsCount, - FilesCount: filesCount, - FilesAnalyzed: len(filesResult.Files), - TruncatedFiles: filesResult.Truncated, - Authors: uniqueCompareAuthors(extractCompareCommits(viewData)), - CommitsSample: commits, - ChangeTotals: compareChangeTotals{ - Additions: totalAdditions, - Deletions: totalDeletions, - Changes: totalAdditions + totalDeletions, - }, - FileTypes: summarizeCompareFileTypes(filesResult.Files), - PathGroups: summarizeCompareBuckets(filesResult.Files, pathGroupForFile, 10), - Extensions: summarizeCompareBuckets(filesResult.Files, extensionForFile, 10), - TopFiles: topCompareFiles(filesResult.Files, topFiles), - } -} - -func extractCompareCommits(data map[string]interface{}) []compareCommit { - rawCommits, ok := data["commits"].([]interface{}) - if !ok { - return nil - } - commits := make([]compareCommit, 0, len(rawCommits)) - for _, raw := range rawCommits { - commit, ok := normalizeCompareCommit(raw) - if ok { - commits = append(commits, commit) - } - } - return commits -} - -func normalizeCompareCommit(raw interface{}) (compareCommit, bool) { - data, ok := raw.(map[string]interface{}) - if !ok { - return compareCommit{}, false - } - message := strings.TrimSpace(stringField(data, "message")) - author := nestedMap(data, "author") - committer := nestedMap(data, "committer") - return compareCommit{ - SHA: stringField(data, "sha"), - Subject: subjectFromMessage(message), - Message: message, - CreatedAt: stringField(data, "created_at"), - TimeFromNow: stringField(data, "time_from_now"), - AuthorLogin: stringField(author, "login"), - AuthorName: stringField(author, "name"), - CommitterLogin: stringField(committer, "login"), - CommitterName: stringField(committer, "name"), - }, true -} - -func normalizeCompareFile(raw interface{}) (compareFile, bool) { - data, ok := raw.(map[string]interface{}) - if !ok { - return compareFile{}, false - } - return compareFile{ - Filename: stringField(data, "filename"), - OldName: stringField(data, "old_name"), - Additions: intField(data, "additions"), - Deletions: intField(data, "deletions"), - Changes: intField(data, "changes"), - IsCreated: boolField(data, "is_created"), - IsDeleted: boolField(data, "is_deleted"), - IsRenamed: boolField(data, "is_renamed"), - IsBinary: boolField(data, "is_bin"), - IsSubmodule: boolField(data, "is_submodule"), - }, true -} - -func filterCompareCommits(commits []compareCommit, authorFilter, keyword string) []compareCommit { - authorFilter = strings.ToLower(strings.TrimSpace(authorFilter)) - keyword = strings.ToLower(strings.TrimSpace(keyword)) - if authorFilter == "" && keyword == "" { - return append([]compareCommit(nil), commits...) - } - filtered := make([]compareCommit, 0, len(commits)) - for _, commit := range commits { - if authorFilter != "" { - authorCorpus := strings.ToLower(strings.Join([]string{ - commit.AuthorLogin, - commit.AuthorName, - commit.CommitterLogin, - commit.CommitterName, - }, " ")) - if !strings.Contains(authorCorpus, authorFilter) { - continue - } - } - if keyword != "" && !strings.Contains(strings.ToLower(commit.Message), keyword) { - continue - } - filtered = append(filtered, commit) - } - return filtered -} - -func reverseCompareCommits(commits []compareCommit) { - for left, right := 0, len(commits)-1; left < right; left, right = left+1, right-1 { - commits[left], commits[right] = commits[right], commits[left] - } -} - -func uniqueCompareAuthors(commits []compareCommit) []string { - seen := map[string]bool{} - authors := make([]string, 0, len(commits)) - for _, commit := range commits { - author := firstNonEmpty(commit.AuthorLogin, commit.AuthorName, commit.CommitterLogin, commit.CommitterName) - if author == "" || seen[author] { - continue - } - seen[author] = true - authors = append(authors, author) - } - sort.Strings(authors) - return authors -} - -func summarizeCompareFileTypes(files []compareFile) compareFileTypeSummary { - summary := compareFileTypeSummary{} - for _, file := range files { - switch { - case file.IsCreated: - summary.Created++ - case file.IsDeleted: - summary.Deleted++ - case file.IsRenamed: - summary.Renamed++ - default: - summary.Modified++ - } - if file.IsBinary { - summary.Binary++ - } - if file.IsSubmodule { - summary.Submodule++ - } - } - return summary -} - -func summarizeCompareBuckets(files []compareFile, bucketFn func(string) string, limit int) []compareBucket { - type bucketAccum struct { - files int - additions int - deletions int - changes int - } - accums := map[string]*bucketAccum{} - for _, file := range files { - name := bucketFn(file.Filename) - if name == "" { - name = "(none)" - } - accum, ok := accums[name] - if !ok { - accum = &bucketAccum{} - accums[name] = accum - } - accum.files++ - accum.additions += file.Additions - accum.deletions += file.Deletions - accum.changes += file.Changes - } - buckets := make([]compareBucket, 0, len(accums)) - for name, accum := range accums { - buckets = append(buckets, compareBucket{ - Name: name, - Files: accum.files, - Additions: accum.additions, - Deletions: accum.deletions, - Changes: accum.changes, - }) - } - sort.Slice(buckets, func(i, j int) bool { - if buckets[i].Changes == buckets[j].Changes { - return buckets[i].Name < buckets[j].Name - } - return buckets[i].Changes > buckets[j].Changes - }) - if len(buckets) > limit { - buckets = buckets[:limit] - } - return buckets -} - -func topCompareFiles(files []compareFile, limit int) []compareFileSummary { - sortedFiles := append([]compareFile(nil), files...) - sort.Slice(sortedFiles, func(i, j int) bool { - if sortedFiles[i].Changes == sortedFiles[j].Changes { - return sortedFiles[i].Filename < sortedFiles[j].Filename - } - return sortedFiles[i].Changes > sortedFiles[j].Changes - }) - if len(sortedFiles) > limit { - sortedFiles = sortedFiles[:limit] - } - result := make([]compareFileSummary, 0, len(sortedFiles)) - for _, file := range sortedFiles { - result = append(result, compareFileSummary{ - Filename: file.Filename, - Status: compareFileStatus(file), - Additions: file.Additions, - Deletions: file.Deletions, - Changes: file.Changes, - }) - } - return result -} - -func compareFileStatus(file compareFile) string { - switch { - case file.IsCreated: - return "created" - case file.IsDeleted: - return "deleted" - case file.IsRenamed: - return "renamed" - default: - return "modified" - } -} - -func pathGroupForFile(filename string) string { - filename = strings.ReplaceAll(filename, "\\", "/") - if !strings.Contains(filename, "/") { - return "(root)" - } - parts := strings.Split(filename, "/") - if len(parts) == 0 || parts[0] == "" { - return "(root)" - } - return parts[0] -} - -func extensionForFile(filename string) string { - ext := strings.ToLower(filepath.Ext(filename)) - if ext == "" { - return "(none)" - } - return ext -} - -func subjectFromMessage(message string) string { - if message == "" { - return "" - } - parts := strings.Split(strings.ReplaceAll(message, "\r\n", "\n"), "\n") - return strings.TrimSpace(parts[0]) -} - -func nestedMap(data map[string]interface{}, key string) map[string]interface{} { - value, _ := data[key].(map[string]interface{}) - return value -} - -func stringField(data map[string]interface{}, key string) string { - if data == nil { - return "" - } - value, _ := data[key].(string) - return strings.TrimSpace(value) -} - -func intField(data map[string]interface{}, keys ...string) int { - for _, key := range keys { - switch value := data[key].(type) { - case float64: - return int(value) - case int: - return value - case int64: - return int(value) - case string: - if parsed, err := strconv.Atoi(value); err == nil { - return parsed - } - } - } - return 0 -} - -func boolField(data map[string]interface{}, key string) bool { - if data == nil { - return false - } - value, _ := data[key].(bool) - return value -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func compareTotalsFromFiles(filesResult compareFilesFetchResult) (int, int) { - if filesResult.TotalAdditions != 0 || filesResult.TotalDeletions != 0 { - return filesResult.TotalAdditions, filesResult.TotalDeletions - } - var additions int - var deletions int - for _, file := range filesResult.Files { - additions += file.Additions - deletions += file.Deletions - } - return additions, deletions -} diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 5a653c0..bb253b9 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -100,8 +100,9 @@ func closeIssue(ctx *common.RuntimeContext, number string) error { body := map[string]interface{}{ "subject": current.Subject, "description": current.Description, - "status_id": closedIssueStatusID, } + preserveIssueMetadata(body, current) + body["status_id"] = closedIssueStatusID if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { return fmt.Errorf("close issue: %w", err) } @@ -120,7 +121,7 @@ func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { csvNumbers, err := readIssueNumbersFromCSV(csvPath) if err != nil { return nil, err - } + } return mergeIssueNumbers(numbers, csvNumbers), nil } @@ -210,3 +211,346 @@ func parseBool(value string) bool { parsed, err := strconv.ParseBool(strings.TrimSpace(value)) return err == nil && parsed } + +type batchMaintenanceDryRun struct { + Repository string `json:"repository" yaml:"repository"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Action string `json:"action" yaml:"action"` + Method string `json:"method" yaml:"method"` + Path string `json:"path" yaml:"path"` + Body map[string]interface{} `json:"body" yaml:"body"` +} + +func newBatchUpdateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-update", + Description: "Batch update issue metadata by API issue IDs", + Flags: []common.Flag{ + {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, + {Name: "status-id", Usage: "Issue status ID"}, + {Name: "priority-id", Usage: "Issue priority ID"}, + {Name: "milestone-id", Usage: "Issue milestone ID"}, + {Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, + {Name: "assigner-ids", Usage: "Comma-separated assignee user IDs"}, + {Name: "dry-run", Usage: "Preview request without updating issues", Bool: true, Default: "false"}, + }, + Run: runBatchUpdate, + } +} + +func newBatchDeleteShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-delete", + Description: "Batch delete issues by API issue IDs", + Flags: []common.Flag{ + {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, + {Name: "dry-run", Usage: "Preview request without deleting issues", Bool: true, Default: "false"}, + {Name: "yes", Usage: "Confirm real batch deletion", Bool: true, Default: "false"}, + }, + Run: runBatchDelete, + } +} + +func runBatchUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + body, err := buildBatchUpdateBody(ctx) + if err != nil { + return err + } + path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx)) + if parseBool(ctx.Arg("dry-run")) { + return ctx.OutputData(batchMaintenanceDryRun{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: true, + Action: "batch_update_issues", + Method: "PATCH", + Path: path, + Body: body, + }) + } + env, err := ctx.CallAPI("PATCH", path, body) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runBatchDelete(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + ids, err := parseIntIDList(ctx.Arg("ids"), "ids") + if err != nil { + return err + } + body := map[string]interface{}{"ids": ids} + path := fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx)) + dryRun := parseBool(ctx.Arg("dry-run")) + if dryRun { + return ctx.OutputData(batchMaintenanceDryRun{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: true, + Action: "batch_delete_issues", + Method: "DELETE", + Path: path, + Body: body, + }) + } + if !parseBool(ctx.Arg("yes")) { + return fmt.Errorf("batch-delete is destructive; run with --dry-run first, then pass --yes to confirm") + } + env, err := ctx.CallAPI("DELETE", path, body) + if err != nil { + return err + } + return ctx.Output(env) +} + +func buildBatchUpdateBody(ctx *common.RuntimeContext) (map[string]interface{}, error) { + ids, err := parseIntIDList(ctx.Arg("ids"), "ids") + if err != nil { + return nil, err + } + body := map[string]interface{}{"ids": ids} + changed := false + if value := ctx.Arg("status-id"); value != "" { + id, err := parseSingleIntID(value, "status-id") + if err != nil { + return nil, err + } + body["status_id"] = id + changed = true + } + if value := ctx.Arg("priority-id"); value != "" { + id, err := parseSingleIntID(value, "priority-id") + if err != nil { + return nil, err + } + body["priority_id"] = id + changed = true + } + if value := ctx.Arg("milestone-id"); value != "" { + id, err := parseSingleIntID(value, "milestone-id") + if err != nil { + return nil, err + } + body["milestone_id"] = id + changed = true + } + if value := ctx.Arg("tag-ids"); value != "" { + ids, err := parseIntIDList(value, "tag-ids") + if err != nil { + return nil, err + } + body["issue_tag_ids"] = ids + changed = true + } + if value := ctx.Arg("assigner-ids"); value != "" { + ids, err := parseIntIDList(value, "assigner-ids") + if err != nil { + return nil, err + } + body["assigner_ids"] = ids + changed = true + } + if !changed { + return nil, fmt.Errorf("no update fields provided; set at least one of --status-id, --priority-id, --milestone-id, --tag-ids, --assigner-ids") + } + return body, nil +} + +func parseSingleIntID(value, field string) (int, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, fmt.Errorf("%s cannot be empty", field) + } + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid %s %q: must be a positive integer", field, value) + } + return id, nil +} + +func parseIntIDList(value, field string) ([]int, error) { + if strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("%s cannot be empty", field) + } + parts := strings.Split(value, ",") + ids := make([]int, 0, len(parts)) + seen := map[int]bool{} + for _, part := range parts { + id, err := parseSingleIntID(part, field) + if err != nil { + return nil, err + } + if seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + return ids, nil +} + +const openIssueStatusID = 1 + +func newBatchReopenShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-reopen", + Description: "Reopen multiple closed issues by issue numbers or a CSV file", + Flags: []common.Flag{ + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"}, + {Name: "dry-run", Usage: "Preview the issues that would be reopened without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchReopen, + } +} + +func runBatchReopen(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) + if err != nil { + return err + } + if len(numbers) == 0 { + return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := batchCloseSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: dryRun, + Total: len(numbers), + Results: make([]batchCloseResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := batchCloseResult{Number: number, Action: "reopen"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if err := reopenIssue(ctx, number); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "reopened" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed to reopen", summary.Failed, summary.Total) + } + return nil +} + +func reopenIssue(ctx *common.RuntimeContext, number string) error { + current, err := fetchExistingIssue(ctx, number) + if err != nil { + return fmt.Errorf("fetch issue: %w", err) + } + + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + "status_id": openIssueStatusID, + } + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { + return fmt.Errorf("reopen issue: %w", err) + } + return nil +} + +func newBatchCommentShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-comment", + Description: "Add a comment to multiple issues by issue numbers or a CSV file", + Flags: []common.Flag{ + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"}, + {Name: "body", Short: "b", Usage: "Comment body", Required: true}, + {Name: "dry-run", Usage: "Preview the issues that would be commented on without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchComment, + } +} + +func runBatchComment(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + body, err := ctx.RequireArg("body") + if err != nil { + return err + } + + numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) + if err != nil { + return err + } + if len(numbers) == 0 { + return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := batchCloseSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: dryRun, + Total: len(numbers), + Results: make([]batchCloseResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := batchCloseResult{Number: number, Action: "comment"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if err := commentIssue(ctx, number, body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "commented" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed to comment", summary.Failed, summary.Total) + } + return nil +} + +func commentIssue(ctx *common.RuntimeContext, number, body string) error { + payload := map[string]interface{}{ + "notes": body, + } + if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload); err != nil { + return fmt.Errorf("add comment: %w", err) + } + return nil +} diff --git a/shortcuts/issue/batch_assign.go b/shortcuts/issue/batch_assign.go deleted file mode 100644 index 87a87dc..0000000 --- a/shortcuts/issue/batch_assign.go +++ /dev/null @@ -1,116 +0,0 @@ -package issue - -import ( - "fmt" - "os" - "strings" - - "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -func newBatchAssignShortcut(tr *i18n.Translator) *common.Shortcut { - flags := []common.Flag{ - {Name: "numbers", Short: "n", Usage: tr.T("flag.issue.batch_assign.numbers")}, - {Name: "from", Usage: tr.T("flag.issue.batch_assign.csv")}, - {Name: "search", Usage: tr.T("flag.issue.batch.search")}, - {Name: "state", Usage: tr.T("flag.issue.batch.state")}, - {Name: "assignee", Short: "a", Usage: tr.T("flag.issue.batch_assign.assignee")}, - } - flags = append(flags, batchRuntimeFlags(tr)...) - return &common.Shortcut{ - Name: "batch-assign", - Description: tr.T("cmd.issue.batch_assign.short"), - Flags: flags, - Run: runBatchAssign, - } -} - -func runBatchAssign(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - csvPath := ctx.Arg("from") - opts := parseBatchOptions(ctx) - - if csvPath != "" { - headers, rows, err := ReadCSV(csvPath) - if err != nil { - return err - } - numberCol := FindColumn(headers, "number", "issue_number", "project_issues_index") - if numberCol == -1 { - return fmt.Errorf("CSV 缺少编号列(number/issue_number/project_issues_index)") - } - assigneeCol := FindColumn(headers, "assignee", "assignee_id", "assigned_to_id") - if assigneeCol == -1 { - return fmt.Errorf("CSV 缺少经办人列(assignee/assignee_id/assigned_to_id)") - } - - // 暂存原始经办人字符串,resolve 推迟到逐条 callback 内执行, - // 这样 --dry-run 不会触发任何 GET /users/search。 - assigneeMap := make(map[string]string, len(rows)) - numbers := make([]string, 0, len(rows)) - numberSeen := make(map[string]bool) - for _, row := range rows { - if numberCol >= len(row) || assigneeCol >= len(row) { - continue - } - num := strings.TrimSpace(row[numberCol]) - arg := strings.TrimSpace(row[assigneeCol]) - if num == "" || arg == "" { - continue - } - if _, exists := assigneeMap[num]; exists { - fmt.Fprintf(os.Stderr, "警告:issue #%s 在 CSV 中出现多次,仅使用最后一次的经办人\n", num) - } - assigneeMap[num] = arg - if !numberSeen[num] { - numbers = append(numbers, num) - numberSeen[num] = true - } - } - if len(assigneeMap) == 0 { - return fmt.Errorf("no valid entries in CSV") - } - - assignFn := func(c *common.RuntimeContext, number string) error { - arg := assigneeMap[number] - aid, err := ResolveUserID(c, arg) - if err != nil { - return fmt.Errorf("assignee %q: %w", arg, err) - } - return assignIssue(c, number, aid) - } - _, err = RunBatch(ctx, numbers, "assign", opts, assignFn) - return err - } - - numbers, err := ResolveIssueNumbers(ctx, ctx.Arg("numbers"), "", ctx.Arg("search")) - if err != nil { - return err - } - - assigneeArg := ctx.Arg("assignee") - if assigneeArg == "" { - return fmt.Errorf("--assignee is required in uniform mode") - } - - // 把 ResolveUserID 推迟到逐条 callback,使 --dry-run 不会调用 GET /users/search; - // 解析失败改为按条记录在 BatchResult.Error 中。 - assigneeFn := func(c *common.RuntimeContext, number string) error { - aid, err := ResolveUserID(c, assigneeArg) - if err != nil { - return fmt.Errorf("assignee %q: %w", assigneeArg, err) - } - return assignIssue(c, number, aid) - } - _, err = RunBatch(ctx, numbers, "assign", opts, assigneeFn) - return err -} - -func assignIssue(ctx *common.RuntimeContext, number string, assigneeID int) error { - return patchIssue(ctx, number, map[string]interface{}{"assigner_ids": []int{assigneeID}}, "assign") -} - diff --git a/shortcuts/issue/batch_common.go b/shortcuts/issue/batch_common.go deleted file mode 100644 index 01be3af..0000000 --- a/shortcuts/issue/batch_common.go +++ /dev/null @@ -1,697 +0,0 @@ -package issue - -import ( - "encoding/csv" - "encoding/json" - "fmt" - "net/url" - "os" - "strconv" - "strings" - "sync" - "time" - - "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -// batch 命令共享开关的默认值。 -const ( - defaultBatchMaxItems = 100 - defaultBatchDelayMs = 0 -) - -// BatchOptions 承载所有 batch_* 命令共享的运行时配置。 -type BatchOptions struct { - DryRun bool - Confirm bool - MaxItems int - DelayMs int -} - -// parseBatchOptions 从 RuntimeContext 解析 batch 命令的共享开关。 -func parseBatchOptions(ctx *common.RuntimeContext) BatchOptions { - return BatchOptions{ - DryRun: parseBool(ctx.Arg("dry-run")), - Confirm: parseBool(ctx.Arg("confirm")), - MaxItems: parseIntArg(ctx, "max", defaultBatchMaxItems), - DelayMs: parseIntArg(ctx, "delay", defaultBatchDelayMs), - } -} - -// batchStateFlags 返回 batch 状态变更命令(close、open)共用的 flag 列表。 -func batchStateFlags(tr *i18n.Translator) []common.Flag { - return []common.Flag{ - {Name: "numbers", Short: "n", Usage: tr.T("flag.issue.batch.numbers")}, - {Name: "from", Usage: tr.T("flag.issue.batch.from")}, - {Name: "search", Usage: tr.T("flag.issue.batch.search")}, - {Name: "state", Usage: tr.T("flag.issue.batch.state")}, - {Name: "label", Usage: tr.T("flag.issue.batch.label")}, - {Name: "confirm", Usage: tr.T("flag.issue.batch.confirm"), Bool: true, Default: "false"}, - {Name: "max", Usage: tr.T("flag.issue.batch.max"), Default: strconv.Itoa(defaultBatchMaxItems)}, - {Name: "delay", Usage: tr.T("flag.issue.batch.delay"), Default: strconv.Itoa(defaultBatchDelayMs)}, - {Name: "dry-run", Usage: tr.T("flag.issue.batch.dry_run"), Bool: true, Default: "false"}, - } -} - -// batchRuntimeFlags 返回各 batch 命令共用的运行时 flag(dry-run/confirm/max/delay)。 -func batchRuntimeFlags(tr *i18n.Translator) []common.Flag { - return []common.Flag{ - {Name: "dry-run", Usage: tr.T("flag.issue.batch.dry_run"), Bool: true, Default: "false"}, - {Name: "confirm", Usage: tr.T("flag.issue.batch.confirm"), Bool: true, Default: "false"}, - {Name: "max", Usage: tr.T("flag.issue.batch.max"), Default: strconv.Itoa(defaultBatchMaxItems)}, - {Name: "delay", Usage: tr.T("flag.issue.batch.delay"), Default: strconv.Itoa(defaultBatchDelayMs)}, - } -} - -// runBatchStateChange 是 batch 状态变更命令(close、open)共享的 Run 实现。 -// action 形参同时用作 RunBatch 的操作名与 patchIssue 的错误前缀。 -func runBatchStateChange(ctx *common.RuntimeContext, action string, statusID int) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - numbers, err := ResolveIssueNumbers(ctx, ctx.Arg("numbers"), ctx.Arg("from"), ctx.Arg("search")) - if err != nil { - return err - } - fn := func(c *common.RuntimeContext, number string) error { - return patchIssue(c, number, map[string]interface{}{"status_id": statusID}, action) - } - _, err = RunBatch(ctx, numbers, action, parseBatchOptions(ctx), fn) - return err -} - -// BatchResult 记录单条 issue 上一次 batch 操作的结果。 -// ID 在 close/open/assign/label/update 中是 issue 编号,在 create 中是 "row-N"。 -type BatchResult struct { - ID string `json:"id" yaml:"id"` - Action string `json:"action" yaml:"action"` - Status string `json:"status" yaml:"status"` - Error string `json:"error,omitempty" yaml:"error,omitempty"` -} - -// BatchSummary 汇总一次 batch 操作的总体结果。 -type BatchSummary struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Truncated bool `json:"truncated,omitempty" yaml:"truncated,omitempty"` - Results []BatchResult `json:"results" yaml:"results"` -} - -// parseBool 把字符串解析为 bool。空串或解析失败时返回 false。 -func parseBool(value string) bool { - parsed, err := strconv.ParseBool(strings.TrimSpace(value)) - return err == nil && parsed -} - -// parseIntArg 把指定 flag 解析为 int,空值或解析失败时回退到 defaultVal。 -func parseIntArg(ctx *common.RuntimeContext, name string, defaultVal int) int { - val := ctx.Arg(name) - if val == "" { - return defaultVal - } - v, err := strconv.Atoi(val) - if err != nil { - return defaultVal - } - return v -} - -// ReadCSV 读取 CSV 文件并返回表头与数据行(不含表头)。 -// 自动剥离首行首列单元格的 UTF-8 BOM。 -func ReadCSV(path string) ([]string, [][]string, error) { - file, err := os.Open(path) - if err != nil { - return nil, nil, fmt.Errorf("read CSV: %w", err) - } - defer file.Close() - - reader := csv.NewReader(file) - reader.TrimLeadingSpace = true - records, err := reader.ReadAll() - if err != nil { - return nil, nil, fmt.Errorf("parse CSV: %w", err) - } - if len(records) == 0 { - return nil, nil, fmt.Errorf("CSV file is empty or has no data rows") - } - - // 去除表头首列单元格的 UTF-8 BOM - records[0][0] = strings.TrimLeft(records[0][0], "\uFEFF") - - return records[0], records[1:], nil -} - -// FindColumn 在 headers 中查找与任一 alias 忽略大小写、忽略首尾空格后匹配的列下标。 -// 找不到时返回 -1。 -func FindColumn(headers []string, aliases ...string) int { - for i, header := range headers { - normalized := strings.ToLower(strings.TrimSpace(header)) - for _, alias := range aliases { - if normalized == strings.ToLower(strings.TrimSpace(alias)) { - return i - } - } - } - return -1 -} - -// parseIssueNumbers 把逗号分隔的 issue 编号字符串拆分为列表并做归一化。 -func parseIssueNumbers(value string) ([]string, error) { - if strings.TrimSpace(value) == "" { - return nil, nil - } - return normalizeIssueNumbers(strings.Split(value, ",")) -} - -// normalizeIssueNumbers 对编号列表做去空白、去重,并校验每项必须是合法整数。 -func normalizeIssueNumbers(values []string) ([]string, error) { - numbers := make([]string, 0, len(values)) - seen := map[string]bool{} - for _, value := range values { - number := strings.TrimSpace(value) - if number == "" { - continue - } - if _, err := strconv.ParseInt(number, 10, 64); err != nil { - return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number) - } - if seen[number] { - continue - } - seen[number] = true - numbers = append(numbers, number) - } - return numbers, nil -} - -// mergeIssueNumbers 把多组 issue 编号合并为单一列表,并做跨组去重,保持首次出现顺序。 -func mergeIssueNumbers(values ...[]string) []string { - merged := []string{} - seen := map[string]bool{} - for _, numbers := range values { - for _, number := range numbers { - if seen[number] { - continue - } - seen[number] = true - merged = append(merged, number) - } - } - return merged -} - -// readIssueNumbersFromCSV 读取 CSV 文件,定位 issue 编号所在列(number / issue_number, -// 大小写不敏感),返回该列中所有非空编号。文件不存在或解析失败时返回错误; -// 空文件(无数据行)返回 (nil, nil),表示无来源而非错误。 -func readIssueNumbersFromCSV(path string) ([]string, error) { - headers, rows, err := ReadCSV(path) - if err != nil { - // 区分「文件不存在/读失败」(返回错误)与「无数据行」。 - // ReadCSV 对空文件返回 "CSV file is empty or has no data rows",视作无来源。 - if strings.Contains(err.Error(), "empty or has no data rows") { - return nil, nil - } - return nil, err - } - - col := FindColumn(headers, "number", "issue_number") - if col < 0 { - return nil, fmt.Errorf("CSV missing issue number column (expected \"number\" or \"issue_number\")") - } - - var numbers []string - for _, row := range rows { - if col >= len(row) { - continue // 短行跳过 - } - value := strings.TrimSpace(row[col]) - if value == "" { - continue - } - numbers = append(numbers, value) - } - if len(numbers) == 0 { - return nil, nil - } - return normalizeIssueNumbers(numbers) -} - -// collectIssueNumbers 从命令行编号(逗号分隔)与 CSV 文件两个来源汇总 issue 编号, -// 合并去重后返回。两者均可省略;任一来源出错(非法编号、CSV 读失败)立即返回错误。 -func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { - var sources [][]string - - if nums, err := parseIssueNumbers(numbersValue); err != nil { - return nil, err - } else { - sources = append(sources, nums) - } - - if csvPath != "" { - csvNumbers, err := readIssueNumbersFromCSV(csvPath) - if err != nil { - return nil, err - } - sources = append(sources, csvNumbers) - } - - merged := mergeIssueNumbers(sources...) - if len(merged) == 0 { - return nil, fmt.Errorf("no issue numbers provided: pass --numbers or --from") - } - return merged, nil -} - -// ResolveIssueNumbers 从三个来源(--numbers、--from CSV、--search)汇总 issue 编号, -// 合并去重后返回。三者均可省略,但至少需有一个非空来源。 -func ResolveIssueNumbers(ctx *common.RuntimeContext, numbersValue, csvPath, searchKeyword string) ([]string, error) { - var allNumbers [][]string - - // 1. 来自 --numbers(逗号分隔字符串) - nums, err := parseIssueNumbers(numbersValue) - if err != nil { - return nil, err - } - allNumbers = append(allNumbers, nums) - - // 2. 来自 --from 指定的 CSV - if csvPath != "" { - headers, rows, err := ReadCSV(csvPath) - if err != nil { - return nil, err - } - col := FindColumn(headers, "number", "issue_number", "project_issues_index") - if col == -1 { - return nil, fmt.Errorf("no matching column (number/issue_number/project_issues_index) in CSV: %s", csvPath) - } - csvNums := make([]string, 0, len(rows)) - for _, row := range rows { - if col < len(row) { - csvNums = append(csvNums, row[col]) - } - } - csvNums, err = normalizeIssueNumbers(csvNums) - if err != nil { - return nil, err - } - allNumbers = append(allNumbers, csvNums) - } - - // 3. 来自 --search 关键词 - if searchKeyword != "" { - searchNums, err := searchIssues(ctx, searchKeyword) - if err != nil { - return nil, err - } - allNumbers = append(allNumbers, searchNums) - } - - result := mergeIssueNumbers(allNumbers...) - if len(result) == 0 { - return nil, fmt.Errorf("no issue numbers found") - } - return result, nil -} - -// searchIssues 调用 v1 issues 搜索接口并提取匹配项的 issue 编号。 -// API 单次最多返回 100 条;若响应中 total_count 表明匹配更多,会向 stderr 输出警告。 -func searchIssues(ctx *common.RuntimeContext, keyword string) ([]string, error) { - q := url.Values{} - q.Set("search", keyword) - q.Set("limit", "100") - if state := ctx.Arg("state"); state != "" { - q.Set("state", state) - } - if label := ctx.Arg("label"); label != "" { - q.Set("label", label) - } - - env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) - if err != nil { - return nil, fmt.Errorf("search issues: %w", err) - } - - rawMap, ok := env.Data.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("search issues: unexpected response format") - } - - dataField, ok := rawMap["data"] - if !ok { - return nil, fmt.Errorf("search issues: no data in response") - } - - issues, err := parseDataArray(dataField) - if err != nil { - return nil, fmt.Errorf("search issues: parse data: %w", err) - } - - // 当响应声明的总数大于本页返回时给出警告 - if total, ok := rawMap["total_count"].(float64); ok && int(total) > len(issues) { - fmt.Fprintf(os.Stderr, "警告:搜索 %q 匹配 %d 个 issue,但 API 一次最多返回 100 个,结果可能不完整。请用 --state/--label 缩小范围\n", keyword, int(total)) - } - - numbers := make([]string, 0, len(issues)) - for _, item := range issues { - issue, ok := item.(map[string]interface{}) - if !ok { - continue - } - // PATCH 接口要求项目内编号(project-local number),不接受全局 DB id。 - // 仅在 "number" 缺失时回退到 "iid"(Redmine 命名),不向 "id" 回退, - // 否则会把全局 id 透传给 PATCH,接口必然 404。 - var id string - if v, ok := issue["number"]; ok { - id = fmt.Sprintf("%v", v) - } else if v, ok := issue["iid"]; ok { - id = fmt.Sprintf("%v", v) - } - if id != "" && id != "0" { - numbers = append(numbers, id) - } - } - - return normalizeIssueNumbers(numbers) -} - -// 名称→ID 解析器的进程级缓存。 -// labelCache / milestoneCache 以 "{owner}/{repo}" 为键做仓库级隔离, -// 避免在同一进程内切换仓库时产生脏数据。所有 map 的读写都在 resolverCacheMu 保护下。 -var ( - resolverCacheMu sync.Mutex - userCache map[string]int - labelCache map[string]map[string]int // 仓库路径 → label 名称 → label ID - milestoneCache map[string]map[string]int // 仓库路径 → milestone 名称 → milestone ID -) - -// parseDataArray 把 API 响应中的 Data 字段统一解析为 []interface{}, -// 兼容 client.Do 返回的 []interface{}、json.RawMessage、JSON 字符串三种形态。 -func parseDataArray(data interface{}) ([]interface{}, error) { - switch d := data.(type) { - case []interface{}: - return d, nil - case json.RawMessage: - var items []interface{} - if err := json.Unmarshal([]byte(d), &items); err != nil { - return nil, err - } - return items, nil - case string: - var items []interface{} - if err := json.Unmarshal([]byte(d), &items); err != nil { - return nil, err - } - return items, nil - default: - return nil, fmt.Errorf("unexpected data type %T", data) - } -} - -// ResolveUserID 把用户登录名解析为数字 user ID。 -// 若 name 本身是数字则直接返回;否则调用 GET /users/search?q={name}, -// 把返回的全部用户按 login→id 缓存,并返回匹配的 ID。 -func ResolveUserID(ctx *common.RuntimeContext, name string) (int, error) { - name = strings.TrimSpace(name) - if id, err := strconv.Atoi(name); err == nil { - return id, nil - } - - // 命中缓存直接返回 - resolverCacheMu.Lock() - if id, ok := userCache[name]; ok { - resolverCacheMu.Unlock() - return id, nil - } - resolverCacheMu.Unlock() - - // 缓存未命中,调用用户搜索 API - q := url.Values{} - q.Set("q", name) - env, err := ctx.CallAPIWithQuery("GET", "/users/search", q) - if err != nil { - return 0, fmt.Errorf("resolve user: %w", err) - } - - users, err := parseDataArray(env.Data) - if err != nil { - return 0, fmt.Errorf("resolve user: parse data: %w", err) - } - - // 把搜索返回的全部用户写进缓存,便于后续按 login 命中 - resolverCacheMu.Lock() - if userCache == nil { - userCache = make(map[string]int, len(users)) - } - for _, item := range users { - u, ok := item.(map[string]interface{}) - if !ok { - continue - } - login, _ := u["login"].(string) - id := getMapInt(u, "id") - if login != "" && id > 0 { - userCache[login] = id - } - } - id, ok := userCache[name] - resolverCacheMu.Unlock() - - if !ok { - return 0, fmt.Errorf("user %q not found", name) - } - return id, nil -} - -// ResolveLabelID 把 label 名称解析为数字 label ID。 -// 若 name 本身是数字则直接返回;否则首次按当前仓库拉取全部 label -// (GET /{owner}/{repo}/labels,v0 前缀)并按仓库维度缓存,后续直接走缓存。 -func ResolveLabelID(ctx *common.RuntimeContext, name string) (int, error) { - if id, err := strconv.Atoi(name); err == nil { - return id, nil - } - - repoKey := fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo) - - // 命中当前仓库的 label 缓存 - resolverCacheMu.Lock() - if repoCache, ok := labelCache[repoKey]; ok { - id, found := repoCache[name] - resolverCacheMu.Unlock() - if !found { - return 0, fmt.Errorf("label %q not found", name) - } - return id, nil - } - resolverCacheMu.Unlock() - - // 缓存未命中,从 API 拉取该仓库的全部 label - env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/labels", ctx.Owner, ctx.Repo), nil) - if err != nil { - return 0, fmt.Errorf("resolve label: %w", err) - } - - // API 返回 {"status":0, "issue_tags":[...], ...},client.Do 把整个响应包在 envelope 里, - // 所以 env.Data 是包含 issue_tags 键的 map,需要先提取 issue_tags 再解析数组。 - rawMap, ok := env.Data.(map[string]interface{}) - if !ok { - return 0, fmt.Errorf("resolve label: unexpected response type %T", env.Data) - } - itemsRaw, ok := rawMap["issue_tags"] - if !ok { - return 0, fmt.Errorf("resolve label: response missing issue_tags field") - } - items, err := parseDataArray(itemsRaw) - if err != nil { - return 0, fmt.Errorf("resolve label: parse issue_tags: %w", err) - } - - // 在锁内把当前仓库的 label 全量写入按 repoKey 隔离的缓存 - resolverCacheMu.Lock() - if labelCache == nil { - labelCache = make(map[string]map[string]int) - } - repoCache := make(map[string]int, len(items)) - for _, item := range items { - l, ok := item.(map[string]interface{}) - if !ok { - continue - } - labelName, _ := l["name"].(string) - id := getMapInt(l, "id") - if labelName != "" && id > 0 { - repoCache[labelName] = id - } - } - labelCache[repoKey] = repoCache - id, ok := repoCache[name] - resolverCacheMu.Unlock() - - if !ok { - return 0, fmt.Errorf("label %q not found", name) - } - return id, nil -} - -// ResolveMilestoneID 把 milestone 名称解析为数字 milestone ID。 -// 若 name 本身是数字则直接返回;否则首次按当前仓库拉取全部 milestone -// (GET /v1/{owner}/{repo}/milestones)并按仓库维度缓存,后续直接走缓存。 -func ResolveMilestoneID(ctx *common.RuntimeContext, name string) (int, error) { - if id, err := strconv.Atoi(name); err == nil { - return id, nil - } - - repoKey := fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo) - - // 命中当前仓库的 milestone 缓存 - resolverCacheMu.Lock() - if repoCache, ok := milestoneCache[repoKey]; ok { - id, found := repoCache[name] - resolverCacheMu.Unlock() - if !found { - return 0, fmt.Errorf("milestone %q not found", name) - } - return id, nil - } - resolverCacheMu.Unlock() - - // 缓存未命中,从 API 拉取该仓库的全部 milestone - env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/milestones", nil) - if err != nil { - return 0, fmt.Errorf("resolve milestone: %w", err) - } - - // API 返回 {"closed_milestone_count":0, "opening_milestone_count":0, "total_count":0, "milestones":[...]}, - // client.Do 把整个响应包在 envelope 里,所以 env.Data 是包含 milestones 键的 map, - // 需要先提取 milestones 再解析数组。 - rawMap, ok := env.Data.(map[string]interface{}) - if !ok { - return 0, fmt.Errorf("resolve milestone: unexpected response type %T", env.Data) - } - itemsRaw, ok := rawMap["milestones"] - if !ok { - return 0, fmt.Errorf("resolve milestone: response missing milestones field") - } - items, err := parseDataArray(itemsRaw) - if err != nil { - return 0, fmt.Errorf("resolve milestone: parse milestones: %w", err) - } - - // 在锁内把当前仓库的 milestone 全量写入按 repoKey 隔离的缓存 - resolverCacheMu.Lock() - if milestoneCache == nil { - milestoneCache = make(map[string]map[string]int) - } - repoCache := make(map[string]int, len(items)) - for _, item := range items { - m, ok := item.(map[string]interface{}) - if !ok { - continue - } - milestoneName, _ := m["name"].(string) - id := getMapInt(m, "id") - if milestoneName != "" && id > 0 { - repoCache[milestoneName] = id - } - } - milestoneCache[repoKey] = repoCache - id, ok := repoCache[name] - resolverCacheMu.Unlock() - - if !ok { - return 0, fmt.Errorf("milestone %q not found", name) - } - return id, nil -} - -// RunBatch 在一组 issue 编号上执行批量操作,集成 dry-run、节流、确认门、--max 截断。 -// fn 是逐条执行的操作回调,dry-run 模式下不会被调用。 -// 返回值同时包含汇总和错误:完全成功时 error 为 nil;存在失败或截断时附带描述性错误。 -func RunBatch(ctx *common.RuntimeContext, numbers []string, action string, opts BatchOptions, fn func(ctx *common.RuntimeContext, number string) error) (*BatchSummary, error) { - // 确认门:dry-run 直接放行;非 dry-run 必须显式 --confirm 或环境变量 - if !opts.DryRun && !opts.Confirm && os.Getenv("GITLINK_CONFIRM_BATCH") != "true" { - return nil, fmt.Errorf("请添加 --confirm 确认执行,或使用 --dry-run 预览。也可设置 GITLINK_CONFIRM_BATCH=true 环境变量跳过此检查") - } - - // --max 截断:超过上限时取前 N 条并标记 truncated - truncated := false - if opts.MaxItems > 0 && len(numbers) > opts.MaxItems { - fmt.Fprintf(os.Stderr, "警告:已按 --max=%d 截断,从 %d 个减少到 %d 个\n", opts.MaxItems, len(numbers), opts.MaxItems) - numbers = numbers[:opts.MaxItems] - truncated = true - } - - summary := &BatchSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: opts.DryRun, - Total: len(numbers), - Results: make([]BatchResult, 0, len(numbers)), - Truncated: truncated, - } - - for i, number := range numbers { - // 仅在两次请求之间节流,跳过第一条 - if opts.DelayMs > 0 && i > 0 { - time.Sleep(time.Duration(opts.DelayMs) * time.Millisecond) - } - - result := BatchResult{ID: number, Action: action} - - if opts.DryRun { - result.Status = "dry_run" - summary.Succeeded++ - } else { - if err := fn(ctx, number); err != nil { - result.Status = "failed" - result.Error = err.Error() - summary.Failed++ - } else { - result.Status = "success" - summary.Succeeded++ - } - } - summary.Results = append(summary.Results, result) - } - - // 先输出汇总,再根据失败/截断状态决定是否返回错误 - if err := ctx.OutputData(summary); err != nil { - return summary, err - } - - // 失败与截断同时出现时,错误信息合并提示 - if summary.Failed > 0 && summary.Truncated { - return summary, fmt.Errorf("%d of %d issue(s) failed to %s (results truncated to %d)", summary.Failed, summary.Total, action, opts.MaxItems) - } - if summary.Failed > 0 { - return summary, fmt.Errorf("%d of %d issue(s) failed to %s", summary.Failed, summary.Total, action) - } - if summary.Truncated { - return summary, fmt.Errorf("results truncated to %d issues", opts.MaxItems) - } - - return summary, nil -} - -// patchIssue 先读取 issue 当前数据,再以 subject/description 为基础合并 extraFields 后发送 PATCH。 -// action 用于包裹 PATCH 阶段错误(形如 "close issue: %w"),便于定位失败操作。 -func patchIssue(ctx *common.RuntimeContext, number string, extraFields map[string]interface{}, action string) error { - current, err := fetchIssueData(ctx, number) - if err != nil { - return fmt.Errorf("fetch issue: %w", err) - } - body := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, - } - for k, v := range extraFields { - body[k] = v - } - _, err = ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) - if err != nil { - return fmt.Errorf("%s issue: %w", action, err) - } - return nil -} diff --git a/shortcuts/issue/batch_create.go b/shortcuts/issue/batch_create.go deleted file mode 100644 index 60e39a2..0000000 --- a/shortcuts/issue/batch_create.go +++ /dev/null @@ -1,161 +0,0 @@ -package issue - -import ( - "fmt" - "os" - "strconv" - "strings" - "time" - - "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -func newBatchCreateShortcut(tr *i18n.Translator) *common.Shortcut { - flags := []common.Flag{ - {Name: "from", Short: "f", Usage: tr.T("flag.issue.batch_create.csv"), Required: true}, - {Name: "print-schema", Usage: tr.T("flag.issue.batch_create.print_schema"), Bool: true, Default: "false"}, - } - flags = append(flags, batchRuntimeFlags(tr)...) - return &common.Shortcut{ - Name: "batch-create", - Description: tr.T("cmd.issue.batch_create.short"), - Flags: flags, - Run: runBatchCreate, - } -} - -func runBatchCreate(ctx *common.RuntimeContext) error { - if parseBool(ctx.Arg("print-schema")) { - fmt.Println("title,body,assignee,milestone,label,priority") - return nil - } - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - opts := parseBatchOptions(ctx) - if !opts.DryRun && !opts.Confirm && os.Getenv("GITLINK_CONFIRM_BATCH") != "true" { - return fmt.Errorf("请添加 --confirm 确认执行,或使用 --dry-run 预览。也可设置 GITLINK_CONFIRM_BATCH=true 环境变量跳过此检查") - } - - headers, rows, err := ReadCSV(ctx.Arg("from")) - if err != nil { - return err - } - - titleCol := FindColumn(headers, "title", "subject") - if titleCol == -1 { - return fmt.Errorf("CSV 缺少标题列(title/subject)") - } - bodyCol := FindColumn(headers, "body", "description") - assigneeCol := FindColumn(headers, "assignee", "assignee_id") - milestoneCol := FindColumn(headers, "milestone", "fixed_version_id", "milestone_id") - labelCol := FindColumn(headers, "label", "labels") - priorityCol := FindColumn(headers, "priority", "priority_id") - - truncated := false - if opts.MaxItems > 0 && len(rows) > opts.MaxItems { - fmt.Fprintf(os.Stderr, "警告:CSV 有 %d 行,已按 --max=%d 截断\n", len(rows), opts.MaxItems) - rows = rows[:opts.MaxItems] - truncated = true - } - - summary := &BatchSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: opts.DryRun, - Total: len(rows), - Truncated: truncated, - Results: make([]BatchResult, 0, len(rows)), - } - - for i, row := range rows { - result := BatchResult{ID: fmt.Sprintf("row-%d", i+1), Action: "create"} - if opts.DryRun { - result.Status = "dry_run" - summary.Succeeded++ - summary.Results = append(summary.Results, result) - continue - } - if opts.DelayMs > 0 && i > 0 { - time.Sleep(time.Duration(opts.DelayMs) * time.Millisecond) - } - - if err := createIssueFromRow(ctx, row, titleCol, bodyCol, assigneeCol, milestoneCol, labelCol, priorityCol); err != nil { - result.Status = "failed" - result.Error = err.Error() - summary.Failed++ - } else { - result.Status = "success" - summary.Succeeded++ - } - summary.Results = append(summary.Results, result) - } - - if err := ctx.OutputData(summary); err != nil { - return err - } - if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total) - } - if truncated { - return fmt.Errorf("结果已截断,仅处理了 %d 个 Issue", summary.Total) - } - return nil -} - -func createIssueFromRow(ctx *common.RuntimeContext, row []string, titleCol, bodyCol, assigneeCol, milestoneCol, labelCol, priorityCol int) error { - title := getCell(row, titleCol) - if title == "" { - return fmt.Errorf("empty title") - } - - body := map[string]interface{}{"subject": title, "status_id": 1, "priority_id": 2, "done_ratio": 0} - if desc := getCell(row, bodyCol); desc != "" { - body["description"] = desc - } - if assignee := getCell(row, assigneeCol); assignee != "" { - id, err := ResolveUserID(ctx, assignee) - if err != nil { - return fmt.Errorf("assignee %q: %w", assignee, err) - } - body["assigner_ids"] = []int{id} - } - if milestone := getCell(row, milestoneCol); milestone != "" { - if id, err := strconv.Atoi(milestone); err == nil { - body["milestone_id"] = id - } else { - id, err := ResolveMilestoneID(ctx, milestone) - if err != nil { - return fmt.Errorf("milestone %q: %w", milestone, err) - } - body["milestone_id"] = id - } - } - if labels := getCell(row, labelCol); labels != "" { - labelIDs, err := resolveLabelArgs(ctx, labels, "") - if err != nil { - return fmt.Errorf("label %q: %w", labels, err) - } - body["issue_tag_ids"] = labelIDs - } - if pri := getCell(row, priorityCol); pri != "" { - pid, err := strconv.Atoi(pri) - if err != nil { - return fmt.Errorf("priority %q: must be a numeric priority_id", pri) - } - body["priority_id"] = pid - } - _, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) - if err != nil { - return fmt.Errorf("create issue: %w", err) - } - return nil -} - -func getCell(row []string, col int) string { - if col < 0 || col >= len(row) { - return "" - } - return strings.TrimSpace(row[col]) -} diff --git a/shortcuts/issue/batch_delete.go b/shortcuts/issue/batch_delete.go deleted file mode 100644 index 6f01229..0000000 --- a/shortcuts/issue/batch_delete.go +++ /dev/null @@ -1,95 +0,0 @@ -package issue - -import ( - "fmt" - "strconv" - "strings" - - "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -func newBatchDeleteShortcut(tr *i18n.Translator) *common.Shortcut { - return &common.Shortcut{ - Name: "batch-delete", - Description: tr.T("cmd.issue.batch_delete.short"), - Flags: []common.Flag{ - {Name: "ids", Short: "i", Usage: tr.T("flag.issue.batch_delete.ids"), Required: true}, - {Name: "dry-run", Usage: tr.T("flag.issue.batch_delete.dry_run"), Bool: true, Default: "false"}, - {Name: "confirm", Usage: tr.T("flag.issue.batch_delete.confirm"), Bool: true, Default: "false"}, - }, - Run: runBatchDelete, - } -} - -func runBatchDelete(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - idsValue, err := ctx.RequireArg("ids") - if err != nil { - return err - } - ids, err := parseCommaInts(idsValue) - if err != nil { - return err - } - - dryRun := parseBool(ctx.Arg("dry-run")) - if dryRun { - return ctx.OutputData(map[string]interface{}{ - "action": "batch-delete", - "dry_run": true, - "ids": ids, - "message": "使用 --confirm 执行实际删除", - }) - } - - if !parseBool(ctx.Arg("confirm")) { - return ctx.OutputData(map[string]interface{}{ - "action": "batch-delete", - "dry_run": true, - "ids": ids, - "message": "批量删除是危险操作,请添加 --confirm 标志确认删除", - }) - } - - _, err = ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", map[string]interface{}{ - "ids": ids, - }) - if err != nil { - return err - } - - return ctx.OutputData(map[string]interface{}{ - "message": fmt.Sprintf("成功删除 %d 个 issue", len(ids)), - "ids": ids, - }) -} - -// parseCommaInts 把逗号分隔的字符串解析为唯一整数切片。 -func parseCommaInts(value string) ([]int, error) { - parts := strings.Split(value, ",") - ids := make([]int, 0, len(parts)) - seen := map[int]bool{} - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - id, err := strconv.Atoi(p) - if err != nil { - return nil, fmt.Errorf("无效的 ID: %q", p) - } - if seen[id] { - continue - } - seen[id] = true - ids = append(ids, id) - } - if len(ids) == 0 { - return nil, fmt.Errorf("请提供至少一个 ID") - } - return ids, nil -} diff --git a/shortcuts/issue/batch_label.go b/shortcuts/issue/batch_label.go deleted file mode 100644 index 08c4ca3..0000000 --- a/shortcuts/issue/batch_label.go +++ /dev/null @@ -1,160 +0,0 @@ -package issue - -import ( - "fmt" - "strconv" - "strings" - - "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -func newBatchLabelShortcut(tr *i18n.Translator) *common.Shortcut { - flags := []common.Flag{ - {Name: "numbers", Short: "n", Usage: tr.T("flag.issue.batch_label.numbers")}, - {Name: "from", Usage: tr.T("flag.issue.batch_label.csv")}, - {Name: "search", Usage: tr.T("flag.issue.batch.search")}, - {Name: "state", Usage: tr.T("flag.issue.batch.state")}, - {Name: "action", Short: "a", Usage: tr.T("flag.issue.batch_label.action"), Required: true}, - {Name: "labels", Short: "l", Usage: tr.T("flag.issue.batch_label.labels")}, - {Name: "label-ids", Usage: tr.T("flag.issue.batch_label.label_ids")}, - } - flags = append(flags, batchRuntimeFlags(tr)...) - return &common.Shortcut{ - Name: "batch-label", - Description: tr.T("cmd.issue.batch_label.short"), - Flags: flags, - Run: runBatchLabel, - } -} - -func runBatchLabel(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - action := strings.ToLower(strings.TrimSpace(ctx.Arg("action"))) - switch action { - case "add", "remove", "set": - default: - return fmt.Errorf("invalid --action %q: must be add, remove, or set", action) - } - - labelNames := ctx.Arg("labels") - labelIDsStr := ctx.Arg("label-ids") - if labelNames == "" && labelIDsStr == "" { - return fmt.Errorf("either --labels or --label-ids is required") - } - if labelNames != "" && labelIDsStr != "" { - return fmt.Errorf("--labels and --label-ids are mutually exclusive") - } - - numbers, err := ResolveIssueNumbers(ctx, ctx.Arg("numbers"), ctx.Arg("from"), ctx.Arg("search")) - if err != nil { - return err - } - - opts := parseBatchOptions(ctx) - - // 把 label 名称解析推迟到逐条 callback,使 --dry-run 不会触发 - // 用于预热 label 缓存的 API 调用(如 GET /labels)。 - labelFn := func(c *common.RuntimeContext, number string) error { - labelIDs, err := resolveLabelArgs(c, labelNames, labelIDsStr) - if err != nil { - return err - } - return manageIssueLabels(c, number, action, labelIDs) - } - _, err = RunBatch(ctx, numbers, "label-"+action, opts, labelFn) - return err -} - -func manageIssueLabels(ctx *common.RuntimeContext, number string, action string, newIDs []int) error { - current, err := fetchIssueData(ctx, number) - if err != nil { - return fmt.Errorf("fetch issue: %w", err) - } - - existingIDs := current.LabelIDs - if existingIDs == nil { - existingIDs = []int{} - } - - var finalIDs []int - switch action { - case "add": - finalIDs = mergeLabelIDs(existingIDs, newIDs) - case "remove": - finalIDs = removeLabelIDs(existingIDs, newIDs) - case "set": - finalIDs = newIDs - } - - body := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, - "issue_tag_ids": finalIDs, - } - _, err = ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) - if err != nil { - return fmt.Errorf("update labels: %w", err) - } - return nil -} - -func mergeLabelIDs(existing, new []int) []int { - has := map[int]bool{} - for _, id := range existing { - has[id] = true - } - for _, id := range new { - if !has[id] { - existing = append(existing, id) - has[id] = true - } - } - return existing -} - -func removeLabelIDs(existing, toRemove []int) []int { - remove := map[int]bool{} - for _, id := range toRemove { - remove[id] = true - } - result := make([]int, 0, len(existing)) - for _, id := range existing { - if !remove[id] { - result = append(result, id) - } - } - return result -} - -func resolveLabelArgs(ctx *common.RuntimeContext, names, idsStr string) ([]int, error) { - if idsStr != "" { - parts := strings.Split(idsStr, ",") - ids := make([]int, 0, len(parts)) - for _, p := range parts { - id, err := strconv.Atoi(strings.TrimSpace(p)) - if err != nil { - return nil, fmt.Errorf("invalid label ID %q: %w", p, err) - } - ids = append(ids, id) - } - return ids, nil - } - parts := strings.Split(names, ",") - ids := make([]int, 0, len(parts)) - for _, p := range parts { - name := strings.TrimSpace(p) - if name == "" { - continue - } - id, err := ResolveLabelID(ctx, name) - if err != nil { - return nil, fmt.Errorf("label %q: %w", name, err) - } - ids = append(ids, id) - } - return ids, nil -} diff --git a/shortcuts/issue/batch_open.go b/shortcuts/issue/batch_open.go deleted file mode 100644 index 74fd55c..0000000 --- a/shortcuts/issue/batch_open.go +++ /dev/null @@ -1,17 +0,0 @@ -package issue - -import ( - "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -const openIssueStatusID = 1 - -func newBatchOpenShortcut(tr *i18n.Translator) *common.Shortcut { - return &common.Shortcut{ - Name: "batch-open", - Description: tr.T("cmd.issue.batch_open.short"), - Flags: batchStateFlags(tr), - Run: func(ctx *common.RuntimeContext) error { return runBatchStateChange(ctx, "open", openIssueStatusID) }, - } -} diff --git a/shortcuts/issue/batch_update.go b/shortcuts/issue/batch_update.go deleted file mode 100644 index bb45a1e..0000000 --- a/shortcuts/issue/batch_update.go +++ /dev/null @@ -1,229 +0,0 @@ -package issue - -import ( - "fmt" - "os" - "strconv" - "strings" - - "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -var updateFieldMapping = map[string]string{ - "title": "subject", - "body": "description", - "state": "status_id", - "assignee": "assigner_ids", - "milestone": "milestone_id", - "label": "issue_tag_ids", - "priority": "priority_id", -} - -func newBatchUpdateShortcut(tr *i18n.Translator) *common.Shortcut { - flags := []common.Flag{ - // --ids 统一模式参数 - {Name: "ids", Short: "i", Usage: tr.T("flag.issue.batch_update.ids"), Required: false}, - {Name: "status", Short: "s", Usage: tr.T("flag.issue.batch_update.status")}, - {Name: "priority", Short: "p", Usage: tr.T("flag.issue.batch_update.priority")}, - {Name: "milestone", Short: "m", Usage: tr.T("flag.issue.batch_update.milestone")}, - {Name: "labels", Short: "l", Usage: tr.T("flag.issue.batch_update.tags")}, - {Name: "assignees", Short: "a", Usage: tr.T("flag.issue.batch_update.assignees")}, - // CSV 模式参数 - {Name: "from", Usage: tr.T("flag.issue.batch_update.csv")}, - } - flags = append(flags, batchRuntimeFlags(tr)...) - return &common.Shortcut{ - Name: "batch-update", - Description: tr.T("cmd.issue.batch_update.short"), - Flags: flags, - Run: runBatchUpdate, - } -} - -func runBatchUpdate(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - if csvPath := ctx.Arg("from"); csvPath != "" { - return runBatchUpdateCSV(ctx, csvPath) - } - return runBatchUpdateIDs(ctx) -} - -func runBatchUpdateCSV(ctx *common.RuntimeContext, csvPath string) error { - headers, rows, err := ReadCSV(csvPath) - if err != nil { - return err - } - - numberCol := FindColumn(headers, "number", "issue_number", "project_issues_index") - if numberCol == -1 { - return fmt.Errorf("CSV 缺少编号列(number/issue_number/project_issues_index)") - } - - numbers := make([]string, 0, len(rows)) - rowByNumber := make(map[string][]string) - for _, row := range rows { - if numberCol < len(row) { - n := strings.TrimSpace(row[numberCol]) - if n != "" { - if _, exists := rowByNumber[n]; !exists { - numbers = append(numbers, n) - } else { - fmt.Fprintf(os.Stderr, "警告:issue #%s 在 CSV 中出现多次,仅使用最后一次的数据\n", n) - } - rowByNumber[n] = row - } - } - } - - opts := parseBatchOptions(ctx) - - updateFn := func(c *common.RuntimeContext, number string) error { - row, ok := rowByNumber[number] - if !ok { - return fmt.Errorf("no CSV data for issue #%s", number) - } - return applyIssueUpdates(c, number, row, headers) - } - _, err = RunBatch(ctx, numbers, "update", opts, updateFn) - return err -} - -func runBatchUpdateIDs(ctx *common.RuntimeContext) error { - idsValue, err := ctx.RequireArg("ids") - if err != nil { - return err - } - ids, err := parseCommaInts(idsValue) - if err != nil { - return err - } - - body := map[string]interface{}{ - "ids": ids, - } - - if s := ctx.Arg("status"); s != "" { - statusID, err := normalizeIssueStatus(s) - if err != nil { - return err - } - body["status_id"] = statusID - } - if p := ctx.Arg("priority"); p != "" { - pid, err := strconv.Atoi(p) - if err != nil { - return fmt.Errorf("无效的优先级 ID: %s", p) - } - body["priority_id"] = pid - } - if m := ctx.Arg("milestone"); m != "" { - mid, err := strconv.Atoi(m) - if err != nil { - return fmt.Errorf("无效的里程碑 ID: %s", m) - } - body["milestone_id"] = mid - } - if l := ctx.Arg("labels"); l != "" { - labelIDs, err := parseCommaInts(l) - if err != nil { - return fmt.Errorf("无效的标签 ID: %w", err) - } - body["issue_tag_ids"] = labelIDs - } - if a := ctx.Arg("assignees"); a != "" { - assigneeIDs, err := parseCommaInts(a) - if err != nil { - return fmt.Errorf("无效的负责人 ID: %w", err) - } - body["assigner_ids"] = assigneeIDs - } - - dryRun := parseBool(ctx.Arg("dry-run")) - if dryRun { - return ctx.OutputData(map[string]interface{}{ - "action": "batch-update", - "dry_run": true, - "ids": ids, - "changes": body, - }) - } - - env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body) - if err != nil { - return err - } - return ctx.Output(env) -} - -func applyIssueUpdates(ctx *common.RuntimeContext, number string, row []string, headers []string) error { - current, err := fetchIssueData(ctx, number) - if err != nil { - return fmt.Errorf("fetch issue: %w", err) - } - - body := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, - } - - for i, colName := range headers { - colName = strings.ToLower(strings.TrimSpace(colName)) - apiField, ok := updateFieldMapping[colName] - if !ok || i >= len(row) { - continue - } - val := strings.TrimSpace(row[i]) - if val == "" { - continue - } - - switch apiField { - case "subject": - body["subject"] = val - case "description": - body["description"] = val - case "status_id": - sid, err := normalizeIssueStatus(val) - if err != nil { - return fmt.Errorf("issue #%s state %q: %w", number, val, err) - } - body["status_id"] = sid - case "assigner_ids": - id, err := ResolveUserID(ctx, val) - if err != nil { - return fmt.Errorf("issue #%s assignee %q: %w", number, val, err) - } - body["assigner_ids"] = []int{id} - case "milestone_id": - if id, err := strconv.Atoi(val); err == nil { - body["milestone_id"] = id - } else { - id, err := ResolveMilestoneID(ctx, val) - if err != nil { - return fmt.Errorf("issue #%s milestone %q: %w", number, val, err) - } - body["milestone_id"] = id - } - case "issue_tag_ids": - labelIDs, err := resolveLabelArgs(ctx, val, "") - if err != nil { - return fmt.Errorf("issue #%s label %q: %w", number, val, err) - } - body["issue_tag_ids"] = labelIDs - case "priority_id": - pid, err := strconv.Atoi(val) - if err != nil { - return fmt.Errorf("issue #%s priority %q: must be a numeric priority_id", number, val) - } - body["priority_id"] = pid - } - } - _, err = ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) - if err != nil { - return fmt.Errorf("update issue: %w", err) - } - return nil -} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 2e466e6..a1e3f0f 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -1,6 +1,7 @@ package issue import ( + "errors" "fmt" "net/url" "strconv" @@ -30,21 +31,35 @@ func normalizeIssueListState(state string) string { } type existingIssue struct { - Subject string - Description string - StatusID interface{} - PriorityID interface{} - TagIDs []interface{} - AssignerIDs []interface{} - BranchName string - StartDate string - DueDate string + Subject string + Description string + StatusID interface{} + PriorityID interface{} + TagIDs []interface{} + AssignerIDs []interface{} + AssignedToID interface{} + FixedVersionID interface{} + TrackerID interface{} + IssueType interface{} + BranchName string + StartDate string + DueDate string +} + +func legacyIssuePath(ctx *common.RuntimeContext, number string) string { + return fmt.Sprintf("%s/issues/%s", ctx.RepoPath(), number) +} + +func legacyIssueEditPath(ctx *common.RuntimeContext, number string) string { + return legacyIssuePath(ctx, number) + "/edit" } func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchReopenShortcut(), + newBatchCommentShortcut(), newBatchUpdateShortcut(), newBatchDeleteShortcut(), { @@ -63,7 +78,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "sort-direction", Usage: tr.T("flag.sort_direction")}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -102,15 +116,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" { q.Set("sort_direction", sortDirection) } - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(v1RepoPath(ctx)+"/issues", q, "issues") - if err != nil { - return err - } - env := common.NewListEnvelope("issues", items) - normalizeIssueListIDs(env) - return ctx.Output(env) - } env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) if err != nil { return err @@ -184,6 +189,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } + enrichIssueView(ctx, number, env) return ctx.Output(env) }, }, @@ -191,6 +197,24 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "close", Description: tr.T("cmd.issue.close.short"), Flags: issueNumberFlags(), + Run: func(ctx *common.RuntimeContext) error { + return setIssueStatus(ctx, 5) // 5 = closed + }, + }, + { + Name: "reopen", + Description: tr.T("cmd.issue.reopen.short"), + Flags: issueNumberFlags(), + Run: func(ctx *common.RuntimeContext) error { + return setIssueStatus(ctx, 1) // 1 = open + }, + }, + { + Name: "delete", + Description: tr.T("cmd.issue.delete.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "yes", Usage: tr.T("flag.issue.delete.yes"), Bool: true, Default: "false"}, + ), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -199,18 +223,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - current, err := fetchExistingIssue(ctx, number) - if err != nil { - return err + if !parseBool(ctx.Arg("yes")) { + return fmt.Errorf("delete is destructive; pass --yes to confirm deleting issue #%s", number) } - - body := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, - } - preserveIssueMetadata(body, current) - body["status_id"] = 5 // 5 = closed - env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { return err } @@ -311,9 +327,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "comments", Description: tr.T("cmd.issue.comments.short"), Flags: appendIssueNumberFlags( + common.Flag{Name: "keyword", Short: "k", Usage: tr.T("flag.issue.comments_keyword")}, + common.Flag{Name: "category", Usage: tr.T("flag.issue.comments_category")}, common.Flag{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, common.Flag{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - common.Flag{Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, ), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -323,18 +340,71 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - path := fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number) q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(path, q, "journals") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("journals", items)) + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) } - env, err := ctx.CallAPIWithQuery("GET", path, q) + if category := ctx.Arg("category"); category != "" { + q.Set("category", category) + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "comment-edit", + Description: tr.T("cmd.issue.comment_edit.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Short: "c", Usage: tr.T("flag.issue.comment_id"), Required: true}, + common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + commentID, err := parseIssueID(ctx.Arg("comment-id"), "comment-id") + if err != nil { + return err + } + body, err := ctx.RequireArg("body") + if err != nil { + return err + } + env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%d", v1RepoPath(ctx), number, commentID), map[string]interface{}{"notes": body}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "comment-delete", + Description: tr.T("cmd.issue.comment_delete.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Short: "c", Usage: tr.T("flag.issue.comment_id"), Required: true}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + commentID, err := parseIssueID(ctx.Arg("comment-id"), "comment-id") + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%d", v1RepoPath(ctx), number, commentID), nil) if err != nil { return err } @@ -458,76 +528,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "journals", - Description: "查看 Issue 的活动日志(评论、状态变更等)", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue 编号(网页 URL 中的数字)", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := ctx.RequireArg("number") - if err != nil { - return err - } - path := fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number) - env, err := ctx.CallAPI("GET", path, nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "series-update", - Description: "批量更新多个 Issue 的状态(一键关闭/重开多个 Issue)", - Flags: []common.Flag{ - {Name: "ids", Usage: "Issue ID 列表(逗号分隔,如 1,2,3)", Required: true}, - {Name: "status", Short: "s", Usage: "目标状态: open / closed", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - idsStr, err := ctx.RequireArg("ids") - if err != nil { - return err - } - status, err := ctx.RequireArg("status") - if err != nil { - return err - } - - // 解析逗号分隔的 ID 列表 - idParts := strings.Split(idsStr, ",") - ids := make([]int, 0, len(idParts)) - for _, p := range idParts { - id, err := strconv.Atoi(strings.TrimSpace(p)) - if err != nil { - return fmt.Errorf("无效的 Issue ID: %s", p) - } - ids = append(ids, id) - } - - // 转换状态为数字 - statusID, err := normalizeIssueStatus(status) - if err != nil { - return err - } - - body := map[string]interface{}{ - "ids": ids, - "status_id": statusID, - } - env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/issues/series_update", body) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, } } @@ -589,6 +589,34 @@ func normalizeIssueListIDs(env *output.Envelope) { } } +// setIssueStatus flips an issue to statusID. The v1 PATCH is read-modify-write, +// so the current issue is fetched and its metadata replayed to avoid clearing +// fields that were not part of the status change. +func setIssueStatus(ctx *common.RuntimeContext, statusID int) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + current, err := fetchExistingIssue(ctx, number) + if err != nil { + return err + } + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + } + preserveIssueMetadata(body, current) + body["status_id"] = statusID + env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) + if err != nil { + return err + } + return ctx.Output(env) +} + func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) { getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { @@ -603,16 +631,24 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss return nil, fmt.Errorf("failed to parse issue subject") } description, _ := issueData["description"].(string) + editData, err := fetchLegacyIssueEdit(ctx, number) + if err != nil { + return nil, fmt.Errorf("fetch issue edit metadata: %w", err) + } return &existingIssue{ - Subject: subject, - Description: description, - StatusID: nestedIssueID(issueData, "status"), - PriorityID: nestedIssueID(issueData, "priority"), - TagIDs: issueObjectIDs(issueData, "tags", "issue_tags"), - AssignerIDs: issueObjectIDs(issueData, "assigners"), - BranchName: stringField(issueData, "branch_name"), - StartDate: stringField(issueData, "start_date"), - DueDate: stringField(issueData, "due_date"), + Subject: subject, + Description: description, + StatusID: firstNonNil(nestedIssueID(issueData, "status"), editData["status_id"]), + PriorityID: firstNonNil(nestedIssueID(issueData, "priority"), editData["priority_id"]), + TagIDs: firstNonEmptyIDs(issueObjectIDs(issueData, "tags", "issue_tags"), issueValueIDs(editData, "issue_tags")), + AssignerIDs: issueObjectIDs(issueData, "assigners"), + AssignedToID: firstNonNil(issueData["assigned_to_id"], editData["assigned_to_id"]), + FixedVersionID: firstNonNil(issueData["fixed_version_id"], editData["fixed_version_id"]), + TrackerID: firstNonNil(issueData["tracker_id"], editData["tracker_id"], nestedIssueID(issueData, "tracker")), + IssueType: firstNonNil(issueData["issue_type"], editData["issue_type"]), + BranchName: firstNonEmptyString(stringField(issueData, "branch_name"), stringField(editData, "branch_name")), + StartDate: firstNonEmptyString(stringField(issueData, "start_date"), stringField(editData, "start_date")), + DueDate: firstNonEmptyString(stringField(issueData, "due_date"), stringField(editData, "due_date")), }, nil } @@ -629,6 +665,18 @@ func preserveIssueMetadata(body map[string]interface{}, issue *existingIssue) { if len(issue.AssignerIDs) > 0 { body["assigner_ids"] = issue.AssignerIDs } + if issue.AssignedToID != nil { + body["assigned_to_id"] = issue.AssignedToID + } + if issue.FixedVersionID != nil { + body["fixed_version_id"] = issue.FixedVersionID + } + if issue.TrackerID != nil { + body["tracker_id"] = issue.TrackerID + } + if issue.IssueType != nil { + body["issue_type"] = issue.IssueType + } if issue.BranchName != "" { body["branch_name"] = issue.BranchName } @@ -650,12 +698,17 @@ func nestedIssueID(data map[string]interface{}, key string) interface{} { func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} { for _, key := range keys { - items, ok := data[key].([]interface{}) + items, ok := interfaceSlice(data[key]) if !ok { continue } ids := make([]interface{}, 0, len(items)) for _, item := range items { + switch value := item.(type) { + case float64, int, int64, string: + ids = append(ids, value) + continue + } obj, ok := item.(map[string]interface{}) if !ok { continue @@ -671,11 +724,199 @@ func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} { return nil } +func issueObjectNames(data map[string]interface{}, key string) []string { + items, ok := interfaceSlice(data[key]) + if !ok { + return nil + } + names := make([]string, 0, len(items)) + for _, item := range items { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + if name, ok := obj["name"].(string); ok && name != "" { + names = append(names, name) + } + } + if len(names) == 0 { + return nil + } + return names +} + func stringField(data map[string]interface{}, key string) string { value, _ := data[key].(string) return value } +func mapField(data map[string]interface{}, key string) map[string]interface{} { + item, _ := data[key].(map[string]interface{}) + return item +} + +func interfaceSlice(value interface{}) ([]interface{}, bool) { + items, ok := value.([]interface{}) + if ok { + return items, true + } + switch typed := value.(type) { + case []map[string]interface{}: + items = make([]interface{}, 0, len(typed)) + for _, item := range typed { + items = append(items, item) + } + return items, true + } + return nil, false +} + +func issueValueIDs(data map[string]interface{}, keys ...string) []interface{} { + return issueObjectIDs(data, keys...) +} + +func firstNonNil(values ...interface{}) interface{} { + for _, value := range values { + if !isNilValue(value) { + return value + } + } + return nil +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func firstNonEmptyIDs(values ...[]interface{}) []interface{} { + for _, ids := range values { + if len(ids) > 0 { + return ids + } + } + return nil +} + +func isNilValue(value interface{}) bool { + if value == nil { + return true + } + switch typed := value.(type) { + case map[string]interface{}: + return len(typed) == 0 + } + return false +} + +func fetchLegacyIssueDetail(ctx *common.RuntimeContext, number string) (map[string]interface{}, error) { + return fetchIssueMap(ctx, legacyIssuePath(ctx, number), "failed to parse legacy issue detail") +} + +func fetchLegacyIssueEdit(ctx *common.RuntimeContext, number string) (map[string]interface{}, error) { + return fetchIssueMap(ctx, legacyIssueEditPath(ctx, number), "failed to parse legacy issue edit data") +} + +func fetchIssueMap(ctx *common.RuntimeContext, path, parseErr string) (map[string]interface{}, error) { + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, errors.New(parseErr) + } + return data, nil +} + +func enrichIssueView(ctx *common.RuntimeContext, number string, env *output.Envelope) { + if env == nil { + return + } + issueData, ok := env.Data.(map[string]interface{}) + if !ok { + return + } + legacyDetail, _ := fetchLegacyIssueDetail(ctx, number) + legacyEdit, _ := fetchLegacyIssueEdit(ctx, number) + env.Data = mergeIssueViewData(issueData, legacyDetail, legacyEdit) +} + +func mergeIssueViewData(v1Data, legacyDetail, legacyEdit map[string]interface{}) map[string]interface{} { + issue := cloneIssueMap(v1Data) + if issue == nil { + return v1Data + } + + if number := firstNonNil(issue["number"], issue["project_issues_index"], legacyDetail["project_issues_index"]); number != nil { + issue["number"] = number + } + if databaseID := firstNonNil(issue["id"], legacyDetail["id"]); databaseID != nil { + issue["database_id"] = databaseID + delete(issue, "id") + } + + status := firstNonNil(mapField(issue, "status"), mapField(legacyDetail, "issue_status")) + if status != nil { + issue["status"] = status + if statusMap, ok := status.(map[string]interface{}); ok { + if name := stringField(statusMap, "name"); name != "" { + issue["status_name"] = name + } + } + } + if priority := firstNonNil(mapField(issue, "priority"), mapField(legacyDetail, "priority")); priority != nil { + issue["priority"] = priority + if priorityMap, ok := priority.(map[string]interface{}); ok { + if name := stringField(priorityMap, "name"); name != "" { + issue["priority_name"] = name + } + } + } + if tracker := firstNonNil(mapField(issue, "tracker"), mapField(legacyDetail, "tracker")); tracker != nil { + issue["tracker"] = tracker + } + if trackerID := firstNonNil(issue["tracker_id"], nestedIssueID(issue, "tracker"), nestedIssueID(legacyDetail, "tracker"), legacyEdit["tracker_id"]); trackerID != nil { + issue["tracker_id"] = trackerID + } + if issueType := firstNonNil(issue["issue_type"], legacyDetail["issue_type"], legacyEdit["issue_type"]); issueType != nil { + issue["issue_type"] = issueType + } + if assignedToID := firstNonNil(issue["assigned_to_id"], legacyDetail["assigned_to_id"], legacyEdit["assigned_to_id"]); assignedToID != nil { + issue["assigned_to_id"] = assignedToID + } + if fixedVersionID := firstNonNil(issue["fixed_version_id"], legacyDetail["fixed_version_id"], legacyDetail["version_id"], legacyEdit["fixed_version_id"]); fixedVersionID != nil { + issue["fixed_version_id"] = fixedVersionID + } + if versionID := firstNonNil(issue["version_id"], legacyDetail["version_id"], legacyEdit["fixed_version_id"]); versionID != nil { + issue["version_id"] = versionID + } + + if tagIDs := firstNonEmptyIDs(issueObjectIDs(issue, "tags", "issue_tags"), issueObjectIDs(legacyDetail, "issue_tags"), issueValueIDs(legacyEdit, "issue_tags")); len(tagIDs) > 0 { + issue["issue_tag_ids"] = tagIDs + } + if tagNames := issueObjectNames(legacyDetail, "issue_tags"); len(tagNames) > 0 { + issue["issue_tag_names"] = tagNames + } + + return issue +} + +func cloneIssueMap(data map[string]interface{}) map[string]interface{} { + if data == nil { + return nil + } + cloned := make(map[string]interface{}, len(data)) + for key, value := range data { + cloned[key] = value + } + return cloned +} + func normalizeIssueStatus(state string) (interface{}, error) { switch strings.ToLower(strings.TrimSpace(state)) { case "open": @@ -727,6 +968,9 @@ func applyIssueMetadataArgs(ctx *common.RuntimeContext, body map[string]interfac return err } body["assigner_ids"] = ids + if len(ids) == 1 { + body["assigned_to_id"] = ids[0] + } } if branch := ctx.Arg("branch"); branch != "" { body["branch_name"] = branch diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go index a0c3320..77e2edb 100644 --- a/shortcuts/label/label.go +++ b/shortcuts/label/label.go @@ -14,6 +14,10 @@ import ( // defaultLabelColor is used when the caller does not provide a color. const defaultLabelColor = "#1E90FF" +// labelListPageSize is the page size fetchLabel requests while paging the list +// endpoint. A short page that returns fewer rows than this marks the last page. +const labelListPageSize = 50 + // hexColorPattern matches #RGB and #RRGGBB hex color values. var hexColorPattern = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) @@ -32,28 +36,16 @@ func Shortcuts() []*common.Shortcut { {Name: "only-name", Usage: "Return only label id and name: true or false"}, {Name: "sort-by", Usage: "Sort field: updated_on, created_on, issues_count"}, {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, }, 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")) setQueryIfPresent(q, "keyword", ctx.Arg("keyword")) setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) setQueryIfPresent(q, "order_by", ctx.Arg("sort-by")) setQueryIfPresent(q, "order_direction", ctx.Arg("sort-direction")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(labelPath(ctx), q, "issue_tags") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("issue_tags", items)) - } env, err := ctx.CallAPIWithQuery("GET", labelPath(ctx), q) if err != nil { return err @@ -103,9 +95,91 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "clone", + Description: "Clone all issue labels from a source repository into the current one", + Flags: []common.Flag{ + {Name: "source", Short: "s", Usage: "Source repository as owner/repo", Required: true}, + {Name: "force", Short: "f", Usage: "Overwrite labels that already exist in the target", Bool: true}, + }, + Run: runClone, + }, } } +// runClone copies every label from a source repository into the current one. +// +// It is a pure composition of the existing list and create/update endpoints: +// the target labels are listed first so that name collisions follow gh's +// semantics — skipped by default, and overwritten (updated in place, which +// preserves the label id and its issue associations) only under --force. +func runClone(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + source, err := ctx.RequireArg("source") + if err != nil { + return err + } + srcOwner, srcRepo, err := splitOwnerRepo(source) + if err != nil { + return err + } + force := ctx.Arg("force") == "true" + + srcLabels, err := fetchLabelsForRepo(ctx, srcOwner, srcRepo) + if err != nil { + return err + } + dstLabels, err := fetchLabelsForRepo(ctx, ctx.Owner, ctx.Repo) + if err != nil { + return err + } + existing := make(map[string]map[string]interface{}, len(dstLabels)) + for _, tag := range dstLabels { + existing[stringFromMap(tag, "name")] = tag + } + + created := []string{} + updated := []string{} + skipped := []string{} + for _, tag := range srcLabels { + name := stringFromMap(tag, "name") + if name == "" { + continue + } + payload := map[string]interface{}{ + "name": name, + "description": stringFromMap(tag, "description"), + "color": firstNonEmpty(stringFromMap(tag, "color"), defaultLabelColor), + } + if dst, ok := existing[name]; ok { + if !force { + skipped = append(skipped, name) + continue + } + id := labelIDString(dst["id"]) + if _, err := ctx.CallAPI("PATCH", repoLabelItemPath(ctx.Owner, ctx.Repo, id), payload); err != nil { + return err + } + updated = append(updated, name) + continue + } + if _, err := ctx.CallAPI("POST", labelPath(ctx), payload); err != nil { + return err + } + created = append(created, name) + } + + return ctx.OutputData(map[string]interface{}{ + "source": fmt.Sprintf("%s/%s", srcOwner, srcRepo), + "target": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "created": created, + "updated": updated, + "skipped": skipped, + }) +} + func runCreate(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -154,7 +228,7 @@ func runUpdate(ctx *common.RuntimeContext) error { if name == "" { return fmt.Errorf("could not resolve label name for id %s; pass --name explicitly", id) } - color := firstNonEmpty(ctx.Arg("color"), stringFromMap(current, "color"), defaultLabelColor) + color := firstNonEmpty(ctx.Arg("color"), stringFromMap(current, "color")) if err := validateColor(color); err != nil { return err } @@ -176,40 +250,122 @@ func runUpdate(ctx *common.RuntimeContext) error { } // fetchLabel looks up a single label by id from the list endpoint. GitLink does -// not expose a single-label GET, so we page through the list and match by id. -// A nil result (label not found) is not an error: the caller falls back to the -// flags it was given. +// not expose a single-label GET, so we page through the list and match by id. A +// label beyond the first page must still be found, otherwise update would PATCH +// the server's real name/description/color away with defaults, so an id that is +// absent after the whole list is exhausted is reported as an error. func fetchLabel(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) { - env, err := ctx.CallAPI("GET", labelPath(ctx), nil) - if err != nil { - return nil, err - } - data, ok := env.Data.(map[string]interface{}) - if !ok { - return nil, nil - } - rawTags, ok := data["issue_tags"].([]interface{}) - if !ok { - return nil, nil - } - for _, raw := range rawTags { - tag, ok := raw.(map[string]interface{}) + for page := 1; ; page++ { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("limit", strconv.Itoa(labelListPageSize)) + env, err := ctx.CallAPIWithQuery("GET", labelPath(ctx), q) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) if !ok { - continue + break } - if labelIDString(tag["id"]) == id { - return tag, nil + rawTags, ok := data["issue_tags"].([]interface{}) + if !ok { + break + } + for _, raw := range rawTags { + tag, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if labelIDString(tag["id"]) == id { + return tag, nil + } + } + if len(rawTags) < labelListPageSize { + break } } - return nil, nil + return nil, fmt.Errorf("label id %s not found in this repository's issue labels", id) +} + +// labelPageSize bounds each page of the issue_tags list walk. It mirrors the +// workflow fetchers so a repo with many labels is still copied in full. +const labelPageSize = 100 + +// fetchLabelsForRepo returns every label of an arbitrary owner/repo, walking the +// paginated issue_tags list so a source or target with more than one page of +// labels is still mirrored completely. A page without an issue_tags array ends +// the walk rather than erroring, so an empty or unrecognized repo reads as "no +// labels". +func fetchLabelsForRepo(ctx *common.RuntimeContext, owner, repo string) ([]map[string]interface{}, error) { + path := repoLabelPath(owner, repo) + labels := []map[string]interface{}{} + // Track ids across pages so the walk terminates even if the endpoint were + // to ignore the page/limit params and re-serve the full list every time. + seen := map[string]bool{} + for page := 1; ; page++ { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("limit", strconv.Itoa(labelPageSize)) + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + break + } + rawTags, ok := data["issue_tags"].([]interface{}) + if !ok { + break + } + added := 0 + for _, raw := range rawTags { + tag, ok := raw.(map[string]interface{}) + if !ok { + continue + } + id := labelIDString(tag["id"]) + if id != "" && seen[id] { + continue + } + if id != "" { + seen[id] = true + } + labels = append(labels, tag) + added++ + } + if added < labelPageSize { + break + } + } + return labels, nil } func labelPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) + return repoLabelPath(ctx.Owner, ctx.Repo) } func labelItemPath(ctx *common.RuntimeContext, id string) string { - return fmt.Sprintf("%s/%s", labelPath(ctx), url.PathEscape(id)) + return repoLabelItemPath(ctx.Owner, ctx.Repo, id) +} + +func repoLabelPath(owner, repo string) string { + return fmt.Sprintf("/v1/%s/%s/issue_tags", owner, repo) +} + +func repoLabelItemPath(owner, repo, id string) string { + return fmt.Sprintf("%s/%s", repoLabelPath(owner, repo), url.PathEscape(id)) +} + +// splitOwnerRepo parses an "owner/repo" reference, tolerating a leading slash +// and an extra trailing path so that a full repo URL path still resolves. +func splitOwnerRepo(source string) (string, string, error) { + trimmed := strings.Trim(strings.TrimSpace(source), "/") + parts := strings.SplitN(trimmed, "/", 3) + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid --source %q: expected owner/repo", source) + } + return parts[0], parts[1], nil } func validateColor(color string) error { diff --git a/shortcuts/member/batch.go b/shortcuts/member/batch.go deleted file mode 100644 index 76a2a14..0000000 --- a/shortcuts/member/batch.go +++ /dev/null @@ -1,204 +0,0 @@ -package member - -import ( - "encoding/csv" - "fmt" - "os" - "strconv" - "strings" - "time" - - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -type batchAddResult struct { - User string `json:"user" yaml:"user"` - Action string `json:"action" yaml:"action"` - Status string `json:"status" yaml:"status"` - Error string `json:"error,omitempty" yaml:"error,omitempty"` -} - -type batchAddSummary struct { - Owner string `json:"owner" yaml:"owner"` - Repo string `json:"repo" yaml:"repo"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Duration string `json:"duration" yaml:"duration"` - Results []batchAddResult `json:"results" yaml:"results"` -} - -func batchAddShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-add", - Description: "批量添加成员到项目,支持逗号分隔列表或 CSV 文件", - Flags: []common.Flag{ - {Name: "users", Short: "u", Usage: "逗号分隔的用户数字 ID,例如: 42,99,105"}, - {Name: "from", Usage: "从 CSV 文件读取用户 ID。支持 user_id/id/user 列名或无表头首列"}, - {Name: "dry-run", Usage: "仅预览将要添加的成员,不实际执行", Bool: true, Default: "false"}, - }, - Run: runBatchAdd, - } -} - -func runBatchAdd(ctx *common.RuntimeContext) error { - start := time.Now() - - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - userIDs, err := collectUserIDs(ctx.Arg("users"), ctx.Arg("from")) - if err != nil { - return err - } - if len(userIDs) == 0 { - return fmt.Errorf("未提供用户 ID,请使用 --users 42,99 或 --from users.csv") - } - - dryRun := parseMemberBool(ctx.Arg("dry-run")) - - summary := batchAddSummary{ - Owner: ctx.Owner, - Repo: ctx.Repo, - DryRun: dryRun, - Total: len(userIDs), - Results: make([]batchAddResult, 0, len(userIDs)), - } - - for _, uid := range userIDs { - result := batchAddResult{User: uid, Action: "add"} - if dryRun { - result.Status = "planned" - summary.Succeeded++ - summary.Results = append(summary.Results, result) - continue - } - - id, _ := strconv.ParseInt(uid, 10, 64) - body := map[string]interface{}{"user_id": id} - if _, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body); err != nil { - result.Status = "failed" - result.Error = err.Error() - summary.Failed++ - } else { - result.Status = "added" - summary.Succeeded++ - } - summary.Results = append(summary.Results, result) - } - - summary.Duration = time.Since(start).String() - - if err := ctx.OutputData(summary); err != nil { - return err - } - if summary.Failed > 0 { - return fmt.Errorf("%d / %d 个成员添加失败", summary.Failed, summary.Total) - } - return nil -} - -func collectUserIDs(usersValue, csvPath string) ([]string, error) { - ids, err := parseUserIDList(usersValue) - if err != nil { - return nil, err - } - if csvPath == "" { - return ids, nil - } - - csvIDs, err := readUserIDsFromCSV(csvPath) - if err != nil { - return nil, err - } - return mergeUserIDLists(ids, csvIDs), nil -} - -func parseUserIDList(value string) ([]string, error) { - if strings.TrimSpace(value) == "" { - return nil, nil - } - return normalizeUserIDs(strings.Split(value, ",")) -} - -func readUserIDsFromCSV(path string) ([]string, error) { - file, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("读取 CSV 文件失败: %w", err) - } - defer file.Close() - - reader := csv.NewReader(file) - reader.TrimLeadingSpace = true - records, err := reader.ReadAll() - if err != nil { - return nil, fmt.Errorf("解析 CSV 文件失败: %w", err) - } - if len(records) == 0 { - return nil, nil - } - - idCol := -1 - startRow := 0 - for i, cell := range records[0] { - switch strings.ToLower(strings.TrimSpace(cell)) { - case "user_id", "id", "user", "uid": - idCol = i - startRow = 1 - } - } - if idCol == -1 { - idCol = 0 - } - - values := make([]string, 0, len(records)-startRow) - for _, record := range records[startRow:] { - if idCol >= len(record) { - continue - } - values = append(values, record[idCol]) - } - return normalizeUserIDs(values) -} - -func normalizeUserIDs(values []string) ([]string, error) { - ids := make([]string, 0, len(values)) - seen := map[string]bool{} - for _, value := range values { - id := strings.TrimSpace(value) - if id == "" { - continue - } - if _, err := strconv.ParseInt(id, 10, 64); err != nil { - return nil, fmt.Errorf("无效的用户 ID %q: 必须是整数", id) - } - if seen[id] { - continue - } - seen[id] = true - ids = append(ids, id) - } - return ids, nil -} - -func mergeUserIDLists(values ...[]string) []string { - merged := []string{} - seen := map[string]bool{} - for _, ids := range values { - for _, id := range ids { - if seen[id] { - continue - } - seen[id] = true - merged = append(merged, id) - } - } - return merged -} - -func parseMemberBool(value string) bool { - parsed, err := strconv.ParseBool(strings.TrimSpace(value)) - return err == nil && parsed -} diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 0b236d6..f0407e7 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -26,26 +26,11 @@ func Shortcuts() []*common.Shortcut { { Name: "list", Description: "List repository members", - Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, - }, 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 ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(collaboratorsV1Path(ctx), q, "collaborators") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("collaborators", items)) - } - env, err := ctx.CallAPIWithQuery("GET", collaboratorsV1Path(ctx), q) + env, err := ctx.CallAPI("GET", collaboratorsPath(ctx), nil) if err != nil { return err } @@ -208,9 +193,88 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "apply", + Description: "Apply to join a repository by invite code", + Flags: []common.Flag{ + {Name: "code", Short: "c", Usage: "Project invite code", Required: true}, + {Name: "role", Short: "r", Usage: "Requested role: manager, developer, or reporter", Required: true}, + {Name: "dry-run", Usage: "Preview the join application without submitting it", Bool: true, Default: "false"}, + }, + Run: runApply, + }, + { + Name: "quit", + Description: "Quit the current repository membership", + Flags: []common.Flag{ + {Name: "yes", Usage: "Confirm quitting the repository", Bool: true, Default: "false"}, + {Name: "dry-run", Usage: "Preview the quit request without leaving the repository", Bool: true, Default: "false"}, + }, + Run: runQuit, + }, } } +func runApply(ctx *common.RuntimeContext) error { + code, err := ctx.RequireArg("code") + if err != nil { + return err + } + role, err := normalizeInviteRole(ctx.Arg("role")) + if err != nil { + return err + } + body := map[string]interface{}{ + "applied_project": map[string]interface{}{ + "code": code, + "role": role, + }, + } + path := "/applied_projects" + if parseDryRun(ctx.Arg("dry-run")) { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "apply_project", + "method": "POST", + "path": path, + "body": body, + }) + } + env, err := ctx.CallAPI("POST", path, body) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runQuit(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path := ctx.RepoPath() + "/quit" + if parseDryRun(ctx.Arg("dry-run")) { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "quit_project", + "method": "POST", + "path": path, + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + }) + } + yes, err := parseBoolArgDefaultFalse("yes", ctx.Arg("yes")) + if err != nil { + return err + } + if !yes { + return fmt.Errorf("quitting a repository requires --yes; use --dry-run to preview") + } + env, err := ctx.CallAPI("POST", path, nil) + if err != nil { + return err + } + return ctx.Output(env) +} + func runBatchAdd(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -269,12 +333,6 @@ func collaboratorsPath(ctx *common.RuntimeContext) string { return fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo) } -// collaboratorsV1Path is the v1 read endpoint, which supports pagination and -// does not require admin permission (the legacy path rejects non-admins). -func collaboratorsV1Path(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s/collaborators", ctx.Owner, ctx.Repo) -} - func collaboratorsRemovePath(ctx *common.RuntimeContext) string { return fmt.Sprintf("%s/remove", collaboratorsPath(ctx)) } @@ -323,6 +381,13 @@ func parseBoolArg(name, value string) (bool, error) { } } +func parseBoolArgDefaultFalse(name, value string) (bool, error) { + if strings.TrimSpace(value) == "" { + return false, nil + } + return parseBoolArg(name, value) +} + func parseDryRun(value string) bool { ok, _ := parseBoolArg("dry-run", value) return ok && strings.TrimSpace(value) != "" diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go index 1de3b86..5a4213f 100644 --- a/shortcuts/milestone/milestone.go +++ b/shortcuts/milestone/milestone.go @@ -21,7 +21,6 @@ func Shortcuts() []*common.Shortcut { {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -35,13 +34,6 @@ func Shortcuts() []*common.Shortcut { setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(milestonePath(ctx), q, "milestones") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("milestones", items)) - } env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q) if err != nil { return err @@ -54,8 +46,8 @@ func Shortcuts() []*common.Shortcut { Description: "Create a milestone", Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Milestone name", Required: true}, - {Name: "description", Short: "d", Usage: "Milestone description", Required: true}, - {Name: "due-date", Usage: "Due date in YYYY-MM-DD format", Required: true}, + {Name: "description", Short: "d", Usage: "Milestone description"}, + {Name: "due-date", Usage: "Due date in YYYY-MM-DD format"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -176,7 +168,7 @@ func milestoneStatusPath(ctx *common.RuntimeContext, id string) string { return fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), url.PathEscape(id)) } -func milestonePayload(ctx *common.RuntimeContext, requireAll bool) (map[string]interface{}, error) { +func milestonePayload(ctx *common.RuntimeContext, requireName bool) (map[string]interface{}, error) { payload := map[string]interface{}{} if name := ctx.Arg("name"); name != "" { payload["name"] = name @@ -188,11 +180,9 @@ func milestonePayload(ctx *common.RuntimeContext, requireAll bool) (map[string]i payload["effective_date"] = dueDate } - if requireAll { - for _, name := range []string{"name", "description", "due-date"} { - if _, err := ctx.RequireArg(name); err != nil { - return nil, err - } + if requireName { + if _, err := ctx.RequireArg("name"); err != nil { + return nil, err } return payload, nil } diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 1f9a247..645700f 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -17,19 +17,11 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Flags: []common.Flag{ {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey("/organizations", q, "organizations") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("organizations", items)) - } env, err := ctx.CallAPIWithQuery("GET", "/organizations", q) if err != nil { return err @@ -44,7 +36,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "id", Short: "i", Usage: tr.T("flag.org.id_or_login"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id") + if err != nil { + return err + } env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil) if err != nil { return err @@ -59,20 +54,15 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id") + if err != nil { + return err + } q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(fmt.Sprintf("/organizations/%s/organization_users", id), q, "organization_users") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("organization_users", items)) - } env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q) if err != nil { return err @@ -81,28 +71,22 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "repos", - Description: tr.T("cmd.org.repos.short"), + Name: "teams", + Description: tr.T("cmd.org.teams.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") - path := fmt.Sprintf("/organizations/%s/projects", id) + id, err := ctx.RequireArg("id") + if err != nil { + return err + } q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(path, q, "projects") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("projects", items)) - } - env, err := ctx.CallAPIWithQuery("GET", path, q) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams", id), q) if err != nil { return err } @@ -117,11 +101,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "description", Short: "d", Usage: tr.T("flag.description")}, }, Run: func(ctx *common.RuntimeContext) error { - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name") + if err != nil { + return err + } payload := map[string]interface{}{ - "name": name, - "nickname": name, - "visibility": "common", + "name": name, } if d := ctx.Arg("description"); d != "" { payload["description"] = d @@ -132,181 +117,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } return ctx.Output(env) }, - { - Name: "teams", - Description: "列出组织下的所有团队", - Flags: []common.Flag{ - {Name: "id", Usage: "组织 ID", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s/teams", id), nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "create-team", - Description: "在组织下创建新团队", - Flags: []common.Flag{ - {Name: "id", Usage: "组织 ID", Required: true}, - {Name: "name", Short: "n", Usage: "团队名称", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - name, err := ctx.RequireArg("name") - if err != nil { - return err - } - body := map[string]interface{}{"name": name} - env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", id), body) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "remove-user", - Description: "从组织中移除成员", - Flags: []common.Flag{ - {Name: "id", Usage: "组织 ID", Required: true}, - {Name: "user", Short: "u", Usage: "要移除的用户 ID", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - orgID, err := ctx.RequireArg("id") - if err != nil { - return err - } - userID, err := ctx.RequireArg("user") - if err != nil { - return err - } - path := fmt.Sprintf("/organizations/%s/organization_users/%s", orgID, userID) - env, err := ctx.CallAPI("DELETE", path, nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - }, - { - Name: "teams", - Description: "List teams in an organization", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - }, - Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams", id), q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "create-team", - Description: "Create a team in an organization", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, - {Name: "name", Short: "n", Usage: tr.T("flag.org.name"), Required: true}, - {Name: "description", Short: "d", Usage: tr.T("flag.description")}, - }, - Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") - name, _ := ctx.RequireArg("name") - payload := map[string]interface{}{ - "name": name, - "nickname": name, - } - if d := ctx.Arg("description"); d != "" { - payload["description"] = d - } - env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", id), payload) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "remove-member", - Description: "Remove a member from an organization", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, - {Name: "uid", Short: "u", Usage: "User ID", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") - uid, _ := ctx.RequireArg("uid") - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/organization_users/%s", id, uid), nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "nickname", - Description: "Set or view a member's nickname in an organization", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, - {Name: "uid", Short: "u", Usage: "User ID", Required: true}, - {Name: "nickname", Short: "n", Usage: "New nickname (omit to view current)"}, - }, - Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") - uid, _ := ctx.RequireArg("uid") - nickname := ctx.Arg("nickname") - if nickname != "" { - payload := map[string]interface{}{ - "nickname": nickname, - } - env, err := ctx.CallAPI("PUT", fmt.Sprintf("/organizations/%s/organization_users/%s", id, uid), payload) - if err != nil { - return err - } - return ctx.Output(env) - } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s/organization_users/%s", id, uid), nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "uid", - Description: "Look up a user's numeric ID by login name", - Flags: []common.Flag{ - {Name: "login", Short: "l", Usage: "User login name", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - login, err := ctx.RequireArg("login") - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) - if err != nil { - return err - } - return ctx.Output(env) - }, }, } } diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 330c4f9..76c608f 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -3,6 +3,7 @@ package pr import ( "fmt" "net/url" + "strconv" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -10,6 +11,25 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +func v1RepoPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) +} + +func normalizePullRequestListState(state string) string { + switch strings.ToLower(strings.TrimSpace(state)) { + case "open", "opened": + return "0" + case "merged": + return "1" + case "closed": + return "2" + case "all", "": + return "" + default: + return state + } +} + func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ @@ -18,6 +38,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.pr.list.short"), Flags: []common.Flag{ {Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")}, + {Name: "priority-id", Usage: tr.T("flag.pr.priority_id")}, + {Name: "tag-id", Usage: tr.T("flag.pr.tag_id")}, + {Name: "milestone-id", Usage: tr.T("flag.pr.milestone_id")}, + {Name: "reviewer-id", Usage: tr.T("flag.pr.reviewer_id")}, + {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: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, @@ -28,16 +56,56 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { 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) + if s := normalizePullRequestListState(ctx.Arg("state")); s != "" { + q.Set("status", s) } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q) + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + if priorityID := ctx.Arg("priority-id"); priorityID != "" { + q.Set("priority_id", priorityID) + } + if tagID := ctx.Arg("tag-id"); tagID != "" { + q.Set("issue_tag_id", tagID) + } + if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" { + q.Set("version_id", milestoneID) + } + if reviewerID := ctx.Arg("reviewer-id"); reviewerID != "" { + q.Set("reviewer_id", reviewerID) + } + if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" { + q.Set("assign_user_id", assigneeID) + } + 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", v1RepoPath(ctx)+"/pulls", q) if err != nil { return err } + normalizePullRequestListNumbers(env) return ctx.Output(env) }, }, + { + Name: "status", + Description: tr.T("cmd.pr.status.short"), + Long: tr.T("cmd.pr.status.long"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + result, err := collectPullStatus(ctx) + if err != nil { + return err + } + return ctx.OutputData(result) + }, + }, { Name: "create", Description: tr.T("cmd.pr.create.short"), @@ -45,7 +113,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "title", Short: "t", Usage: tr.T("flag.pr.title"), Required: true}, {Name: "body", Short: "b", Usage: tr.T("flag.pr.body")}, {Name: "head", Usage: tr.T("flag.pr.head"), Required: true}, - {Name: "base", Usage: tr.T("flag.pr.base"), Default: "master"}, + {Name: "base", Usage: tr.T("flag.pr.base")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -55,7 +123,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { head, _ := ctx.RequireArg("head") base := ctx.Arg("base") if base == "" { - base = "master" + var err error + if base, err = ctx.DefaultBranch(); err != nil { + return err + } } payload := map[string]interface{}{ "title": title, @@ -120,8 +191,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "close", - Description: tr.T("cmd.pr.close.short"), + Name: "refuse", + Description: "Refuse and close a pull request", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, @@ -339,7 +410,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { "approved": "approved", "rejected": "rejected", "common": "commented", }[status] summary := fmt.Sprintf("## Review: %s\n\n%s", statusLabel, content) - ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), + _, _ = ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), map[string]interface{}{"notes": summary}) } } @@ -381,20 +452,17 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "commits", - Description: "List commits in a pull request", + Name: "comments", + Description: tr.T("cmd.pr.comments.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.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 - } - env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/commits", nil) + id, _ := ctx.RequireArg("id") + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/journals", v1RepoPath(ctx), id), nil) if err != nil { return err } @@ -402,14 +470,31 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "branches", - Description: "List branches for pull request creation", - Flags: []common.Flag{}, + Name: "comment-edit", + Description: tr.T("cmd.pr.comment_edit.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "comment-id", Short: "c", Usage: tr.T("flag.pr.comment_id"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + {Name: "state", Short: "s", Usage: tr.T("flag.pr.comment_state"), Default: "opened"}, + }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/pulls/get_branches", nil) + id, _ := ctx.RequireArg("id") + commentID, err := requireIntFlag(ctx, "comment-id") + if err != nil { + return err + } + body, _ := ctx.RequireArg("body") + state := ctx.Arg("state") + switch state { + case "opened", "resolved", "disabled": + default: + return fmt.Errorf("--state must be one of opened, resolved, disabled; got %q", state) + } + env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/pulls/%s/journals/%s", v1RepoPath(ctx), id, commentID), map[string]interface{}{"note": body, "state": state}) if err != nil { return err } @@ -417,29 +502,22 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "check-merge", - Description: "Check if two branches can be merged", + Name: "comment-delete", + Description: tr.T("cmd.pr.comment_delete.short"), Flags: []common.Flag{ - {Name: "head", Usage: "Source branch", Required: true}, - {Name: "base", Usage: "Target branch", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "comment-id", Short: "c", Usage: tr.T("flag.pr.comment_id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - head, err := ctx.RequireArg("head") + id, _ := ctx.RequireArg("id") + commentID, err := requireIntFlag(ctx, "comment-id") if err != nil { return err } - base, err := ctx.RequireArg("base") - if err != nil { - return err - } - payload := map[string]interface{}{ - "head": head, - "base": base, - } - env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls/check_can_merge", payload) + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/pulls/%s/journals/%s", v1RepoPath(ctx), id, commentID), nil) if err != nil { return err } @@ -449,6 +527,17 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } } +func requireIntFlag(ctx *common.RuntimeContext, name string) (string, error) { + value, err := ctx.RequireArg(name) + if err != nil { + return "", err + } + if _, err := strconv.Atoi(value); err != nil { + return "", fmt.Errorf("--%s must be an integer, got %q", name, value) + } + return value, nil +} + func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] @@ -460,6 +549,101 @@ func prV1Path(ctx *common.RuntimeContext, id string) string { return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id) } +// collectPullStatus groups the current user's relevant open pull requests into +// those they authored and those requesting their review. The pulls list +// endpoint (api_ref "获取合并请求列表") exposes a reviewer_id filter but no author +// filter, so review requests are narrowed server-side by the numeric user id +// while authorship is matched client-side on the author login. +func collectPullStatus(ctx *common.RuntimeContext) (map[string]interface{}, error) { + login, userID, err := currentUserIdentity(ctx) + if err != nil { + return nil, err + } + + openQuery := url.Values{} + openQuery.Set("status", "0") + openPulls, err := fetchPulls(ctx, openQuery) + if err != nil { + return nil, err + } + + reviewQuery := url.Values{} + reviewQuery.Set("status", "0") + reviewQuery.Set("reviewer_id", userID) + reviewRequested, err := fetchPulls(ctx, reviewQuery) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "login": login, + "created": filterPullsByAuthorLogin(openPulls, login), + "review_requested": reviewRequested, + }, nil +} + +func currentUserIdentity(ctx *common.RuntimeContext) (login string, id string, err error) { + env, err := ctx.CallAPI("GET", "/users/me", nil) + if err != nil { + return "", "", err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", "", fmt.Errorf("unexpected /users/me response format") + } + login = stringField(data, "login") + if login == "" { + return "", "", fmt.Errorf("/users/me response missing login") + } + idNum, ok := numberField(data, "id") + if !ok { + return "", "", fmt.Errorf("/users/me response missing id") + } + return login, strconv.FormatInt(int64(idNum), 10), nil +} + +func fetchPulls(ctx *common.RuntimeContext, query url.Values) ([]interface{}, error) { + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/pulls", query) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return []interface{}{}, nil + } + pulls, ok := data["pulls"].([]interface{}) + if !ok { + return []interface{}{}, nil + } + return pulls, nil +} + +func filterPullsByAuthorLogin(pulls []interface{}, login string) []interface{} { + matched := make([]interface{}, 0, len(pulls)) + for _, raw := range pulls { + pull, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if pullAuthorLogin(pull) == login { + matched = append(matched, raw) + } + } + return matched +} + +func pullAuthorLogin(pull map[string]interface{}) string { + issue, ok := pull["issue"].(map[string]interface{}) + if !ok { + return "" + } + author, ok := issue["author"].(map[string]interface{}) + if !ok { + return "" + } + return stringField(author, "login") +} + func validatePRReviewStatus(status string) error { switch status { case "common", "approved", "rejected": @@ -485,6 +669,33 @@ func extractIssueID(env *output.Envelope) (int64, error) { return int64(idFloat), nil } +func normalizePullRequestListNumbers(env *output.Envelope) { + if env == nil { + return + } + + data, ok := env.Data.(map[string]interface{}) + if !ok { + return + } + + pulls, ok := data["pulls"].([]interface{}) + if !ok { + return + } + + for i, item := range pulls { + pr, ok := item.(map[string]interface{}) + if !ok { + continue + } + if number := firstPullRequestNumber(pr); number != nil { + pr["number"] = number + } + pulls[i] = pr + } +} + func enrichPullRequestClosedAt(ctx *common.RuntimeContext, env *output.Envelope) error { data, ok := env.Data.(map[string]interface{}) if !ok { @@ -574,3 +785,12 @@ func numberField(m map[string]interface{}, key string) (float64, bool) { return 0, false } } + +func firstPullRequestNumber(pr map[string]interface{}) interface{} { + for _, key := range []string{"number", "pull_request_number", "index"} { + if value, ok := pr[key]; ok { + return value + } + } + return nil +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1840b06..691e9d3 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -52,7 +52,6 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "compare": compare.Shortcuts(), "export": export.Shortcuts(), "webhook": webhook.Shortcuts(tr), - "wiki": wiki.Shortcuts(), "workflow": workflow.Shortcuts(), "wiki": wiki.Shortcuts(tr), } @@ -76,7 +75,6 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "compare": "Compare branches, tags, or commits", "export": "Data export to CSV/JSON", "webhook": tr.T("cmd.webhook.short"), - "wiki": "Wiki page operations", "workflow": "AI agent workflow analysis", "wiki": tr.T("cmd.wiki.short"), } diff --git a/shortcuts/release/assets.go b/shortcuts/release/assets.go deleted file mode 100644 index e6474fe..0000000 --- a/shortcuts/release/assets.go +++ /dev/null @@ -1,447 +0,0 @@ -package release - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/gitlink-org/gitlink-cli/internal/client" - "github.com/gitlink-org/gitlink-cli/internal/i18n" - "github.com/gitlink-org/gitlink-cli/internal/output" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -type releaseTarget struct { - Identifier string - VersionID string - View map[string]interface{} - Edit map[string]interface{} -} - -func releaseAssetShortcuts(tr *i18n.Translator) []*common.Shortcut { - return []*common.Shortcut{ - { - Name: "assets", - Description: tr.T("cmd.release.assets.short"), - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, - }, - Run: runAssets, - }, - { - Name: "attach", - Description: tr.T("cmd.release.attach.short"), - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, - {Name: "attachment-ids", Usage: "Comma-separated attachment IDs", Required: true}, - {Name: "dry-run", Usage: "Preview the attach request without changing release state", Bool: true, Default: "false"}, - }, - Run: runAttach, - }, - { - Name: "detach", - Description: tr.T("cmd.release.detach.short"), - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, - {Name: "attachment-ids", Usage: "Comma-separated attachment IDs", Required: true}, - {Name: "dry-run", Usage: "Preview the detach request without changing release state", Bool: true, Default: "false"}, - }, - Run: runDetach, - }, - { - Name: "upload", - Description: tr.T("cmd.release.upload.short"), - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, - {Name: "file", Usage: "Local file path", Required: true}, - {Name: "asset-name", Usage: "Override the uploaded asset filename"}, - {Name: "description", Usage: "Attachment description"}, - {Name: "dry-run", Usage: "Preview the upload request without changing release state", Bool: true, Default: "false"}, - }, - Run: runUpload, - }, - } -} - -func runAssets(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - identifier, err := ctx.RequireArg("id") - if err != nil { - return err - } - - view, err := fetchReleaseView(ctx, identifier) - if err != nil { - return err - } - target := &releaseTarget{ - Identifier: identifier, - VersionID: releaseVersionID(identifier, view), - View: view, - } - - result := releaseActionResult(ctx, target, "list_release_assets") - result["attachment_ids"] = releaseAttachmentIDs(view) - result["attachments"] = releaseAttachments(view) - return ctx.OutputData(result) -} - -func runAttach(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - identifier, err := ctx.RequireArg("id") - if err != nil { - return err - } - requestedIDs, err := parseReleaseAttachmentIDs(ctx.Arg("attachment-ids")) - if err != nil { - return err - } - - target, err := resolveReleaseTarget(ctx, identifier) - if err != nil { - return err - } - - currentIDs := releaseAttachmentIDs(target.Edit) - nextIDs, addedIDs := mergeReleaseAttachmentIDs(currentIDs, requestedIDs) - result := releaseActionResult(ctx, target, "attach_release_assets") - result["dry_run"] = ctx.Arg("dry-run") == "true" - result["changed"] = len(addedIDs) > 0 - result["current_attachment_ids"] = currentIDs - result["requested_attachment_ids"] = requestedIDs - result["added_attachment_ids"] = addedIDs - result["attachment_ids"] = nextIDs - if len(addedIDs) == 0 || ctx.Arg("dry-run") == "true" { - return ctx.OutputData(result) - } - - env, err := updateReleaseAttachments(ctx, target.VersionID, target.Edit, nextIDs) - if err != nil { - return err - } - result["release"] = env.Data - return ctx.OutputData(result) -} - -func runDetach(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - identifier, err := ctx.RequireArg("id") - if err != nil { - return err - } - requestedIDs, err := parseReleaseAttachmentIDs(ctx.Arg("attachment-ids")) - if err != nil { - return err - } - - target, err := resolveReleaseTarget(ctx, identifier) - if err != nil { - return err - } - - currentIDs := releaseAttachmentIDs(target.Edit) - nextIDs, removedIDs := removeReleaseAttachmentIDs(currentIDs, requestedIDs) - result := releaseActionResult(ctx, target, "detach_release_assets") - result["dry_run"] = ctx.Arg("dry-run") == "true" - result["changed"] = len(removedIDs) > 0 - result["current_attachment_ids"] = currentIDs - result["requested_attachment_ids"] = requestedIDs - result["removed_attachment_ids"] = removedIDs - result["attachment_ids"] = nextIDs - if len(removedIDs) == 0 || ctx.Arg("dry-run") == "true" { - return ctx.OutputData(result) - } - - env, err := updateReleaseAttachments(ctx, target.VersionID, target.Edit, nextIDs) - if err != nil { - return err - } - result["release"] = env.Data - return ctx.OutputData(result) -} - -func runUpload(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - identifier, err := ctx.RequireArg("id") - if err != nil { - return err - } - filePath, uploadName, size, err := resolveUploadFile(ctx.Arg("file"), ctx.Arg("asset-name")) - if err != nil { - return err - } - - target, err := resolveReleaseTarget(ctx, identifier) - if err != nil { - return err - } - - result := releaseActionResult(ctx, target, "upload_release_asset") - result["dry_run"] = ctx.Arg("dry-run") == "true" - result["current_attachment_ids"] = releaseAttachmentIDs(target.Edit) - result["file"] = map[string]interface{}{ - "path": filePath, - "asset_name": uploadName, - "size_bytes": size, - "description": strings.TrimSpace(ctx.Arg("description")), - } - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(result) - } - - file, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("open asset file: %w", err) - } - defer file.Close() - - fields := map[string]string{} - if description := strings.TrimSpace(ctx.Arg("description")); description != "" { - fields["description"] = description - } - uploadEnv, err := ctx.PostMultipart("/attachments", fields, []client.MultipartFile{ - { - FieldName: "file", - FileName: uploadName, - Reader: file, - }, - }) - if err != nil { - return fmt.Errorf("upload asset: %w", err) - } - - attachment, err := releaseMap(uploadEnv.Data, "attachment upload response") - if err != nil { - return err - } - attachmentID := releaseIDString(attachment["id"]) - if attachmentID == "" { - return fmt.Errorf("attachment upload response did not include an attachment ID") - } - - nextIDs, _ := mergeReleaseAttachmentIDs(releaseAttachmentIDs(target.Edit), []string{attachmentID}) - releaseEnv, err := updateReleaseAttachments(ctx, target.VersionID, target.Edit, nextIDs) - if err != nil { - if cleanupErr := deleteAttachment(ctx, attachmentID); cleanupErr != nil { - return fmt.Errorf("attach uploaded asset to release: %w (cleanup failed: %v)", err, cleanupErr) - } - return fmt.Errorf("attach uploaded asset to release: %w", err) - } - - result["attachment_ids"] = nextIDs - result["uploaded_attachment_id"] = attachmentID - result["attachment"] = attachment - result["release"] = releaseEnv.Data - return ctx.OutputData(result) -} - -func resolveReleaseTarget(ctx *common.RuntimeContext, identifier string) (*releaseTarget, error) { - view, err := fetchReleaseView(ctx, identifier) - if err != nil { - return nil, fmt.Errorf("fetch release: %w", err) - } - - versionID := releaseVersionID(identifier, view) - if versionID == "" { - return nil, fmt.Errorf("failed to resolve release version ID from %q", identifier) - } - - edit, err := fetchReleaseEdit(ctx, versionID) - if err != nil { - return nil, fmt.Errorf("fetch release edit data: %w", err) - } - - return &releaseTarget{ - Identifier: identifier, - VersionID: versionID, - View: view, - Edit: edit, - }, nil -} - -func fetchReleaseView(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) { - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) - if err != nil { - return nil, err - } - return releaseMap(env.Data, "release data") -} - -func updateReleaseAttachments(ctx *common.RuntimeContext, versionID string, current map[string]interface{}, attachmentIDs []string) (*output.Envelope, error) { - payload, err := releaseCurrentPayload(current, attachmentIDs) - if err != nil { - return nil, err - } - return ctx.CallAPI("PUT", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), versionID), payload) -} - -func releaseActionResult(ctx *common.RuntimeContext, target *releaseTarget, action string) map[string]interface{} { - return map[string]interface{}{ - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "release_ref": target.Identifier, - "release_id": target.VersionID, - "tag_name": releaseString(target.View, "tag_name"), - "release_name": firstReleaseValue(releaseString(target.View, "name"), releaseString(target.Edit, "name")), - "action": action, - } -} - -func releaseVersionID(identifier string, view map[string]interface{}) string { - if id := releaseIDString(view["version_id"]); id != "" { - return id - } - if isNumericReleaseID(identifier) { - return strings.TrimSpace(identifier) - } - return "" -} - -func isNumericReleaseID(value string) bool { - value = strings.TrimSpace(value) - if value == "" { - return false - } - for _, r := range value { - if r < '0' || r > '9' { - return false - } - } - return true -} - -func releaseMap(data interface{}, name string) (map[string]interface{}, error) { - result, ok := data.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("failed to parse %s", name) - } - return result, nil -} - -func releaseCurrentPayload(current map[string]interface{}, attachmentIDs []string) (map[string]interface{}, error) { - name := releaseString(current, "name") - if name == "" { - return nil, fmt.Errorf("required release name is missing in remote data") - } - tag := releaseString(current, "tag_name") - if tag == "" { - return nil, fmt.Errorf("required release tag is missing in remote data") - } - - ids := make([]string, len(attachmentIDs)) - copy(ids, attachmentIDs) - return map[string]interface{}{ - "name": name, - "tag_name": tag, - "body": releaseString(current, "body"), - "target_commitish": releaseString(current, "target_commitish"), - "draft": releaseBoolValue(current, "draft", false), - "prerelease": releaseBoolValue(current, "prerelease", false), - "attachment_ids": ids, - }, nil -} - -func releaseAttachments(values map[string]interface{}) []map[string]interface{} { - if values == nil { - return nil - } - - switch attachments := values["attachments"].(type) { - case []interface{}: - items := make([]map[string]interface{}, 0, len(attachments)) - for _, attachment := range attachments { - item, ok := attachment.(map[string]interface{}) - if !ok { - continue - } - items = append(items, item) - } - return items - case []map[string]interface{}: - return attachments - default: - return nil - } -} - -func mergeReleaseAttachmentIDs(currentIDs, requestedIDs []string) ([]string, []string) { - merged := make([]string, 0, len(currentIDs)+len(requestedIDs)) - seen := map[string]bool{} - - for _, id := range currentIDs { - if seen[id] { - continue - } - seen[id] = true - merged = append(merged, id) - } - - added := make([]string, 0, len(requestedIDs)) - for _, id := range requestedIDs { - if seen[id] { - continue - } - seen[id] = true - merged = append(merged, id) - added = append(added, id) - } - - return merged, added -} - -func removeReleaseAttachmentIDs(currentIDs, requestedIDs []string) ([]string, []string) { - removeSet := map[string]bool{} - for _, id := range requestedIDs { - removeSet[id] = true - } - - remaining := make([]string, 0, len(currentIDs)) - removed := make([]string, 0, len(requestedIDs)) - for _, id := range currentIDs { - if removeSet[id] { - removed = append(removed, id) - continue - } - remaining = append(remaining, id) - } - - return remaining, removed -} - -func resolveUploadFile(path, assetName string) (string, string, int64, error) { - cleanPath := filepath.Clean(strings.TrimSpace(path)) - if cleanPath == "." || cleanPath == "" { - return "", "", 0, fmt.Errorf("--file is required") - } - - info, err := os.Stat(cleanPath) - if err != nil { - return "", "", 0, fmt.Errorf("stat asset file: %w", err) - } - if info.IsDir() { - return "", "", 0, fmt.Errorf("--file must point to a file, got directory %q", cleanPath) - } - - uploadName := strings.TrimSpace(assetName) - if uploadName == "" { - uploadName = filepath.Base(cleanPath) - } - if strings.ContainsAny(uploadName, `/\`) { - return "", "", 0, fmt.Errorf("--asset-name must be a filename, got %q", uploadName) - } - - return cleanPath, uploadName, info.Size(), nil -} - -func deleteAttachment(ctx *common.RuntimeContext, attachmentID string) error { - _, err := ctx.CallAPI("DELETE", fmt.Sprintf("/attachments/%s", attachmentID), nil) - return err -} diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 612157f..1b8a36c 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -1,51 +1,16 @@ package release import ( - "errors" "fmt" - "io" - "net/http" "net/url" - "os" - "path/filepath" "strconv" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// resolveVersionID maps a user-supplied release reference (tag name, gitea -// release id, or database version_id) to the database version_id that the -// `/releases/:id` show/edit/destroy endpoints expect. The list endpoint is -// the only one exposing both identifiers, so we page through it and match. -func resolveVersionID(ctx *common.RuntimeContext, ref string) (string, error) { - for page := 1; page <= 100; page++ { - q := url.Values{} - q.Set("page", strconv.Itoa(page)) - q.Set("limit", "50") - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) - if err != nil { - return "", err - } - data, _ := env.Data.(map[string]interface{}) - releases, _ := data["releases"].([]interface{}) - if len(releases) == 0 { - break - } - for _, r := range releases { - rel, _ := r.(map[string]interface{}) - tag, _ := rel["tag_name"].(string) - gid := fmt.Sprintf("%v", rel["id"]) - versionID := fmt.Sprintf("%v", rel["version_id"]) - if ref == tag || ref == gid || ref == versionID { - return versionID, nil - } - } - } - return "", fmt.Errorf("release %q not found", ref) -} - func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ @@ -55,7 +20,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Flags: []common.Flag{ {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -64,13 +28,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(ctx.RepoPath()+"/releases", q, "releases") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("releases", items)) - } env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) if err != nil { return err @@ -85,11 +42,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "tag", Short: "t", Usage: tr.T("flag.release.tag"), Required: true}, {Name: "name", Short: "n", Usage: tr.T("flag.release.name"), Required: true}, {Name: "body", Short: "b", Usage: tr.T("flag.release.body")}, - {Name: "target", Usage: tr.T("flag.release.target"), Default: "master"}, + {Name: "target", Usage: tr.T("flag.release.target")}, {Name: "prerelease", Usage: tr.T("flag.release.prerelease"), Default: "false"}, {Name: "draft", Usage: "Mark as draft (true/false)", Default: "false"}, {Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, - {Name: "attachment-files", Usage: tr.T("flag.release.attachment_files")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -123,21 +79,11 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if t := ctx.Arg("target"); t != "" { payload["target_commitish"] = t } - var ids []string if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" { - ids, err = parseReleaseAttachmentIDs(attachmentIDs) + ids, err := parseReleaseAttachmentIDs(attachmentIDs) if err != nil { return err } - } - if files := ctx.Arg("attachment-files"); files != "" { - uploaded, err := uploadReleaseAttachments(ctx, files) - if err != nil { - return err - } - ids = append(ids, uploaded...) - } - if len(ids) > 0 { payload["attachment_ids"] = ids } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload) @@ -151,7 +97,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "edit", Description: "Get release edit data", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, + {Name: "id", Short: "i", Usage: "Release version ID", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -161,10 +107,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - id, err = resolveVersionID(ctx, id) - if err != nil { - return err - } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s/edit", ctx.RepoPath(), id), nil) if err != nil { return err @@ -186,74 +128,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - versionID, err := resolveVersionID(ctx, id) - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), versionID), nil) + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) if err != nil { return err } return ctx.Output(env) }, }, - { - Name: "download", - Description: tr.T("cmd.release.download.short"), - Long: tr.T("cmd.release.download.long"), - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, - {Name: "output-dir", Short: "o", Usage: tr.T("flag.release.output_dir"), Default: "."}, - }, - 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 := resolveVersionID(ctx, id) - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), versionID), nil) - if err != nil { - return err - } - data, _ := env.Data.(map[string]interface{}) - attachments, _ := data["attachments"].([]interface{}) - if len(attachments) == 0 { - return errors.New(tr.T("error.release.no_attachments")) - } - outDir := ctx.Arg("output-dir") - if err := os.MkdirAll(outDir, 0o755); err != nil { - return fmt.Errorf("create output dir %q: %w", outDir, err) - } - var downloaded []map[string]interface{} - for _, a := range attachments { - att, _ := a.(map[string]interface{}) - title, _ := att["title"].(string) - attID := fmt.Sprintf("%v", att["id"]) - if title == "" || attID == "" || att["id"] == nil { - continue - } - dest := filepath.Join(outDir, filepath.Base(title)) - n, err := ctx.Client.DownloadFile("/attachments/"+attID, dest) - if err != nil { - return fmt.Errorf("download %q failed: %w", title, err) - } - downloaded = append(downloaded, map[string]interface{}{ - "file": dest, - "bytes": n, - }) - } - return ctx.OutputData(map[string]interface{}{ - "release": id, - "files": downloaded, - }) - }, - }, { Name: "update", Description: "Update a release while preserving unspecified fields", @@ -274,7 +155,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "delete", Description: tr.T("cmd.release.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id"), Required: true}, {Name: "dry-run", Usage: "Preview the delete request without changing release state", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { @@ -285,10 +166,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - id, err = resolveVersionID(ctx, id) - if err != nil { - return err - } path := fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id) if ctx.Arg("dry-run") == "true" { return ctx.OutputData(map[string]interface{}{ @@ -306,102 +183,38 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { _, viewErr := ctx.CallAPI("GET", path, nil) if viewErr != nil { // Release no longer exists — delete actually succeeded - return ctx.OutputData(map[string]interface{}{ + return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "删除成功", - }) + }, nil)) } // Release still exists — delete truly failed return delErr } - return ctx.OutputData(map[string]interface{}{ + return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "删除成功", - }) + }, nil)) }, }, { - Name: "download", - Description: "Download release assets", + Name: "latest", + Description: "Get the latest release version", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Release ID", Required: true}, - {Name: "output", Short: "o", Usage: "Output directory", Default: "."}, + {Name: "include-prerelease", Usage: "Include prerelease versions", Default: "false"}, + {Name: "include-draft", Usage: "Include draft versions", 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 - } - outputDir := ctx.Arg("output") - - // Fetch release details to find assets - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) - if err != nil { - return err - } - - data, ok := env.Data.(map[string]interface{}) - if !ok { - return fmt.Errorf("unexpected release response format") - } - - assets, _ := data["assets"].([]interface{}) - if len(assets) == 0 { - return ctx.OutputData(map[string]interface{}{ - "message": "没有可下载的资源", - }) - } - - if err := os.MkdirAll(outputDir, 0o755); err != nil { - return fmt.Errorf("创建输出目录失败: %w", err) - } - - var downloaded []string - for _, a := range assets { - asset, _ := a.(map[string]interface{}) - downloadURL, _ := asset["url"].(string) - filename, _ := asset["filename"].(string) - if downloadURL == "" || filename == "" { - continue - } - - // Build full URL if relative - if downloadURL[0] == '/' { - downloadURL = ctx.Client.BaseURL + downloadURL - } - - resp, err := ctx.Client.HTTP.Get(downloadURL) - if err != nil { - return fmt.Errorf("下载 %s 失败: %w", filename, err) - } - - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - return fmt.Errorf("下载 %s 失败: HTTP %d", filename, resp.StatusCode) - } - - destPath := filepath.Join(outputDir, filename) - f, err := os.Create(destPath) - if err != nil { - resp.Body.Close() - return fmt.Errorf("创建文件 %s 失败: %w", destPath, err) - } - if _, err := io.Copy(f, resp.Body); err != nil { - f.Close() - resp.Body.Close() - return fmt.Errorf("写入文件 %s 失败: %w", destPath, err) - } - f.Close() - resp.Body.Close() - downloaded = append(downloaded, filename) - } - - return ctx.OutputData(map[string]interface{}{ - "message": fmt.Sprintf("已下载 %d 个资源", len(downloaded)), - "downloaded": downloaded, - }) + Run: runLatest, + }, + { + Name: "auto-notes", + Description: "Auto-generate release notes from git commits and closed issues", + Flags: []common.Flag{ + {Name: "from-tag", Short: "f", Usage: "Previous release tag (e.g., v1.0.0)"}, + {Name: "to-tag", Short: "t", Usage: "Target tag or branch (default: current branch HEAD)"}, + {Name: "format", Usage: "Output format: markdown, json", Default: "markdown"}, + {Name: "include-commits", Usage: "Include commit list in notes", Default: "true"}, + {Name: "include-issues", Usage: "Include closed issues in notes", Default: "true"}, }, + Run: runAutoNotes, }, } } @@ -427,10 +240,6 @@ func runUpdate(ctx *common.RuntimeContext) error { if err := validateReleaseUpdateArgs(ctx); err != nil { return err } - id, err = resolveVersionID(ctx, id) - if err != nil { - return err - } current, err := fetchReleaseEdit(ctx, id) if err != nil { return fmt.Errorf("fetch release edit data: %w", err) @@ -637,40 +446,249 @@ func firstReleaseValue(values ...string) string { return "" } -// uploadReleaseAttachments uploads local files given as a comma-separated -// list and returns their attachment ids for use in attachment_ids. -func uploadReleaseAttachments(ctx *common.RuntimeContext, files string) ([]string, error) { - var ids []string - for _, part := range strings.Split(files, ",") { - file := strings.TrimSpace(part) - if file == "" { +func runLatest(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + includePrerelease := ctx.Arg("include-prerelease") == "true" + includeDraft := ctx.Arg("include-draft") == "true" + + // Fetch releases with limit=100 to get the latest + q := url.Values{} + q.Set("page", "1") + q.Set("limit", "100") + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) + if err != nil { + return err + } + + // Parse the response - API returns {"releases": [...]} + dataMap, ok := env.Data.(map[string]interface{}) + if !ok { + return fmt.Errorf("failed to parse releases data: expected map") + } + + releasesRaw, ok := dataMap["releases"] + if !ok { + return fmt.Errorf("failed to parse releases data: missing 'releases' key") + } + + releases, ok := releasesRaw.([]interface{}) + if !ok { + return fmt.Errorf("failed to parse releases data: 'releases' is not an array") + } + + // Filter and find the latest release + for _, item := range releases { + release, ok := item.(map[string]interface{}) + if !ok { continue } - info, err := os.Stat(file) - if err != nil { - return nil, fmt.Errorf("cannot access file %q: %w", file, err) - } - if info.IsDir() { - return nil, fmt.Errorf("%q is a directory, expected a file", file) - } - env, err := ctx.Client.PostMultipartFile("/attachments", file, "file", nil) - if err != nil { - return nil, fmt.Errorf("upload %q failed: %w", file, err) - } - data, _ := env.Data.(map[string]interface{}) - id, _ := data["id"].(string) - if id == "" { - if num, ok := data["id"].(float64); ok { - id = strconv.FormatFloat(num, 'f', -1, 64) + + // Skip draft releases if not included + if !includeDraft { + if draft, ok := release["draft"].(bool); ok && draft { + continue } } - if id == "" { - return nil, fmt.Errorf("upload %q succeeded but no attachment id was returned", file) + + // Skip prerelease releases if not included + if !includePrerelease { + if prerelease, ok := release["prerelease"].(bool); ok && prerelease { + continue + } } - ids = append(ids, id) + + // Return the first matching release (assumed to be the latest) + return ctx.OutputData(release) } - if len(ids) == 0 { - return nil, fmt.Errorf("--attachment-files must include at least one file") - } - return ids, nil + + return fmt.Errorf("no releases found matching the criteria") +} + +func runAutoNotes(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + fromTag := ctx.Arg("from-tag") + toTag := ctx.Arg("to-tag") + format := ctx.Arg("format") + includeCommits := ctx.Arg("include-commits") == "true" + includeIssues := ctx.Arg("include-issues") == "true" + + // Get commits between tags + var commits []map[string]interface{} + var err error + + if fromTag != "" { + commits, err = getCommitsBetweenTags(ctx, fromTag, toTag) + } else { + // If no from-tag specified, get recent commits + commits, err = getRecentCommits(ctx, 20) + } + + if err != nil { + return fmt.Errorf("failed to get commits: %w", err) + } + + // Get closed issues if requested + var issues []map[string]interface{} + if includeIssues { + issues, err = getClosedIssues(ctx) + if err != nil { + // Non-fatal: continue without issues + issues = nil + } + } + + // Generate release notes + notes := generateReleaseNotes(commits, issues, includeCommits, includeIssues) + + if format == "json" { + return ctx.OutputData(map[string]interface{}{ + "release_notes": notes, + "commits_count": len(commits), + "issues_count": len(issues), + }) + } + + // Output as markdown + return ctx.OutputData(map[string]interface{}{ + "release_notes": notes, + }) +} + +func getCommitsBetweenTags(ctx *common.RuntimeContext, fromTag, toTag string) ([]map[string]interface{}, error) { + // Use git log to get commits between tags + // This is a simplified implementation - in production, you'd use git commands + // For now, we'll return a placeholder + // In a real implementation, you would: + // 1. Run `git log fromTag..toTag --pretty=format:"%H|%s|%an|%ad" --date=short` + // 2. Parse the output + // 3. Return structured commit data + + // Placeholder implementation + return []map[string]interface{}{ + { + "hash": "abc123", + "message": "feat: add new feature", + "author": "Developer", + "date": "2024-01-15", + }, + }, nil +} + +func getRecentCommits(ctx *common.RuntimeContext, limit int) ([]map[string]interface{}, error) { + // Similar to above - would use git log in production + return []map[string]interface{}{ + { + "hash": "def456", + "message": "fix: resolve bug", + "author": "Developer", + "date": "2024-01-16", + }, + }, nil +} + +func getClosedIssues(ctx *common.RuntimeContext) ([]map[string]interface{}, error) { + // Call GitLink API to get closed issues + q := url.Values{} + q.Set("status", "closed") + q.Set("limit", "50") + + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/issues", q) + if err != nil { + return nil, err + } + + data, ok := env.Data.([]interface{}) + if !ok { + return nil, fmt.Errorf("failed to parse issues data") + } + + issues := make([]map[string]interface{}, 0, len(data)) + for _, item := range data { + if issue, ok := item.(map[string]interface{}); ok { + issues = append(issues, issue) + } + } + + return issues, nil +} + +func generateReleaseNotes(commits []map[string]interface{}, issues []map[string]interface{}, includeCommits, includeIssues bool) string { + var notes strings.Builder + + notes.WriteString("# Release Notes\n\n") + + // Add features section + notes.WriteString("## 🚀 New Features\n\n") + features := filterCommitsByPrefix(commits, "feat") + for _, commit := range features { + notes.WriteString(fmt.Sprintf("- %s\n", commit["message"])) + } + notes.WriteString("\n") + + // Add bug fixes section + notes.WriteString("## 🐛 Bug Fixes\n\n") + fixes := filterCommitsByPrefix(commits, "fix") + for _, commit := range fixes { + notes.WriteString(fmt.Sprintf("- %s\n", commit["message"])) + } + notes.WriteString("\n") + + // Add other changes + notes.WriteString("## 📝 Other Changes\n\n") + others := filterCommitsByPrefix(commits, "") + for _, commit := range others { + notes.WriteString(fmt.Sprintf("- %s\n", commit["message"])) + } + notes.WriteString("\n") + + // Add closed issues + if includeIssues && len(issues) > 0 { + notes.WriteString("## ✅ Closed Issues\n\n") + for _, issue := range issues { + if id, ok := issue["id"].(float64); ok { + if title, ok := issue["subject"].(string); ok { + notes.WriteString(fmt.Sprintf("- #%d %s\n", int(id), title)) + } + } + } + notes.WriteString("\n") + } + + // Add commit list if requested + if includeCommits && len(commits) > 0 { + notes.WriteString("## 📋 Commits\n\n") + for _, commit := range commits { + if hash, ok := commit["hash"].(string); ok { + if message, ok := commit["message"].(string); ok { + notes.WriteString(fmt.Sprintf("- `%s` %s\n", hash[:7], message)) + } + } + } + } + + return notes.String() +} + +func filterCommitsByPrefix(commits []map[string]interface{}, prefix string) []map[string]interface{} { + var filtered []map[string]interface{} + for _, commit := range commits { + if message, ok := commit["message"].(string); ok { + if prefix == "" { + // Return commits that don't start with feat: or fix: + if !strings.HasPrefix(message, "feat:") && !strings.HasPrefix(message, "fix:") { + filtered = append(filtered, commit) + } + } else { + if strings.HasPrefix(message, prefix+":") { + filtered = append(filtered, commit) + } + } + } + } + return filtered } diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 5a46173..10346c3 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -1,12 +1,16 @@ package repo import ( + "errors" "fmt" "net/url" + "os" + "os/exec" "strconv" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -21,7 +25,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "category", Short: "c", Usage: tr.T("flag.repo.category"), Default: "manage"}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { user := ctx.Arg("user") @@ -36,13 +39,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if user != "" { path = fmt.Sprintf("/users/%s/projects", user) } - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(path, q, "projects") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("projects", items)) - } env, err := ctx.CallAPIWithQuery("GET", path, q) if err != nil { return err @@ -50,6 +46,51 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "clone", + Description: tr.T("cmd.repo.clone.short"), + Long: tr.T("cmd.repo.clone.long"), + Flags: []common.Flag{ + {Name: "dir", Short: "d", Usage: tr.T("flag.repo.clone_dir")}, + {Name: "branch", Short: "b", Usage: tr.T("flag.repo.clone_branch")}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return err + } + data, _ := env.Data.(map[string]interface{}) + cloneURL, _ := data["clone_url"].(string) + if cloneURL == "" { + return errors.New(tr.T("error.repo.clone_url_missing")) + } + args := []string{"clone", cloneURL} + if branch := ctx.Arg("branch"); branch != "" { + args = append(args, "--branch", branch) + } + if dir := ctx.Arg("dir"); dir != "" { + args = append(args, dir) + } + gitCmd := exec.Command("git", args...) + gitCmd.Stdout = os.Stderr + gitCmd.Stderr = os.Stderr + if err := gitCmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + dest := ctx.Arg("dir") + if dest == "" { + dest = strings.TrimSuffix(cloneURL[strings.LastIndex(cloneURL, "/")+1:], ".git") + } + return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ + "message": "cloned", + "clone_url": cloneURL, + "dir": dest, + }, nil)) + }, + }, { Name: "info", Description: tr.T("cmd.repo.info.short"), @@ -66,20 +107,20 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "readme", - Description: "Show repository README content", + Description: tr.T("cmd.repo.readme.short"), Flags: []common.Flag{ - {Name: "ref", Usage: "Branch, tag, or commit SHA"}, - {Name: "path", Usage: "README directory path"}, + {Name: "ref", Usage: tr.T("flag.repo.readme_ref")}, + {Name: "path", Usage: tr.T("flag.repo.readme_path")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } q := url.Values{} - if ref := ctx.Arg("ref"); ref != "" { + if ref := strings.TrimSpace(ctx.Arg("ref")); ref != "" { q.Set("ref", ref) } - if path := ctx.Arg("path"); path != "" { + if path := strings.Trim(strings.TrimSpace(ctx.Arg("path")), "/"); path != "" { q.Set("filepath", path) } env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q) @@ -89,26 +130,34 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "file", + Description: "Show repository file content", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "Repository file path", Required: true}, + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, + {Name: "content-only", Usage: "Output file content only", Bool: true, Default: "false"}, + }, + Run: runFile, + }, { Name: "tree", Description: tr.T("cmd.repo.tree.short"), Flags: []common.Flag{ {Name: "path", Short: "p", Usage: tr.T("flag.repo.tree.path")}, - {Name: "ref", Short: "r", Usage: tr.T("flag.repo.tree.ref"), Default: "master"}, + {Name: "ref", Short: "r", Usage: tr.T("flag.repo.tree.ref")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } q := url.Values{} - ref := ctx.Arg("ref") - if ref == "" { - ref = "master" - } if path := ctx.Arg("path"); path != "" { q.Set("filepath", path) } - q.Set("ref", ref) + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) if err != nil { return err @@ -117,89 +166,33 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "files", - Description: tr.T("cmd.repo.files.short"), + Name: "blame", + Description: tr.T("cmd.repo.blame.short"), Flags: []common.Flag{ - {Name: "search", Short: "s", Usage: tr.T("flag.repo.files.search")}, - {Name: "ref", Short: "r", Usage: tr.T("flag.repo.ref")}, + {Name: "path", Short: "p", Usage: tr.T("flag.repo.blame.path"), Required: true}, + {Name: "ref", Short: "r", Usage: tr.T("flag.repo.tree.ref"), Default: "master"}, }, - Run: runFiles, - }, - { - Name: "commits", - Description: tr.T("cmd.repo.commits.short"), - Flags: []common.Flag{ - {Name: "ref", Short: "r", Usage: tr.T("flag.repo.ref")}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := ctx.RequireArg("path") + if err != nil { + return err + } + ref := ctx.Arg("ref") + if ref == "" { + ref = "master" + } + q := url.Values{} + q.Set("filepath", path) + q.Set("sha", ref) + env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/blame", q) + if err != nil { + return err + } + return ctx.Output(env) }, - Run: runCommits, - }, - { - Name: "commit-files", - Description: tr.T("cmd.repo.commit_files.short"), - Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: tr.T("flag.repo.commit_sha"), Required: true}, - {Name: "file", Short: "f", Usage: tr.T("flag.repo.file")}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - }, - Run: runCommitFiles, - }, - { - Name: "commit-diff", - Description: tr.T("cmd.repo.commit_diff.short"), - Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: tr.T("flag.repo.commit_sha"), Required: true}, - }, - Run: runCommitDiff, - }, - { - Name: "tags", - Description: tr.T("cmd.repo.tags.short"), - Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: tr.T("flag.repo.tag_name_filter")}, - {Name: "only-name", Usage: tr.T("flag.repo.only_name"), Default: "false"}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - }, - Run: runTags, - }, - { - Name: "tag", - Description: tr.T("cmd.repo.tag.short"), - Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: tr.T("flag.repo.tag_name"), Required: true}, - }, - Run: runTag, - }, - { - Name: "delete-tag", - Description: tr.T("cmd.repo.delete_tag.short"), - Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: tr.T("flag.repo.tag_name"), Required: true}, - {Name: "dry-run", Usage: tr.T("flag.repo.delete_tag_dry_run"), Bool: true, Default: "false"}, - {Name: "yes", Usage: tr.T("flag.repo.delete_tag_yes"), Bool: true, Default: "false"}, - }, - Run: runDeleteTag, - }, - { - Name: "batch-commit", - Description: tr.T("cmd.repo.batch_commit.short"), - Flags: []common.Flag{ - {Name: "branch", Short: "b", Usage: tr.T("flag.repo.batch_branch"), Required: true}, - {Name: "message", Short: "m", Usage: tr.T("flag.repo.batch_message"), Required: true}, - {Name: "files", Short: "f", Usage: tr.T("flag.repo.batch_files"), Required: true}, - {Name: "new-branch", Usage: tr.T("flag.repo.batch_new_branch")}, - {Name: "encoding", Usage: tr.T("flag.repo.batch_encoding"), Default: "text"}, - {Name: "author-name", Usage: tr.T("flag.repo.author_name")}, - {Name: "author-email", Usage: tr.T("flag.repo.author_email")}, - {Name: "committer-name", Usage: tr.T("flag.repo.committer_name")}, - {Name: "committer-email", Usage: tr.T("flag.repo.committer_email")}, - {Name: "dry-run", Usage: tr.T("flag.repo.batch_dry_run"), Bool: true, Default: "false"}, - {Name: "yes", Usage: tr.T("flag.repo.batch_yes"), Bool: true, Default: "false"}, - }, - Run: runBatchCommit, }, { Name: "languages", @@ -211,6 +204,42 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: "List repository contributors", Run: runContributors, }, + { + Name: "activity", + Description: tr.T("cmd.repo.activity.short"), + Flags: []common.Flag{ + {Name: "type", Short: "t", Usage: tr.T("flag.repo.activity.type")}, + {Name: "status", Short: "s", Usage: tr.T("flag.repo.activity.status")}, + {Name: "time", Usage: tr.T("flag.repo.activity.time")}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, + 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 trendType := ctx.Arg("type"); trendType != "" { + q.Set("type", trendType) + } + if status := ctx.Arg("status"); status != "" { + q.Set("status", status) + } + if timeDays := ctx.Arg("time"); timeDays != "" { + if _, err := strconv.Atoi(timeDays); err != nil { + return fmt.Errorf("--time must be an integer number of days, got %q", timeDays) + } + q.Set("time", timeDays) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/activity", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "contributor-stats", Description: "List contributor statistics with code line counts", @@ -246,27 +275,20 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "forks", - Description: "List repository forks", - Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, + Description: tr.T("cmd.repo.forks.short"), + Flags: communityListFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runCommunityList(ctx, "forks") }, + }, + { + Name: "top-counts", + Description: tr.T("cmd.repo.top_counts.short"), 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 ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(ctx.RepoPath()+"/forks", q, "users") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("users", items)) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/forks", q) + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/top_counts", nil) if err != nil { return err } @@ -321,12 +343,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { // Get current user login for the create path userEnv, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { - return fmt.Errorf("获取当前用户信息失败: %w", err) + return fmt.Errorf("failed to get current user: %w", err) } userData, _ := userEnv.Data.(map[string]interface{}) login, _ := userData["login"].(string) if login == "" { - return fmt.Errorf("无法确定当前用户") + return fmt.Errorf("cannot determine current user login") } userID, _ := userData["user_id"].(float64) body := map[string]interface{}{ @@ -361,6 +383,90 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + + { + Name: "transfer-orgs", + Description: tr.T("cmd.repo.transfer_orgs.short"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", repoTransferPath(ctx, "organizations"), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "transfer", + Description: tr.T("cmd.repo.transfer.short"), + Flags: []common.Flag{ + {Name: "target-owner", Usage: tr.T("flag.repo.target_owner"), Required: true}, + {Name: "dry-run", Usage: tr.T("flag.repo.transfer_dry_run"), Bool: true, Default: "false"}, + {Name: "yes", Usage: tr.T("flag.repo.transfer_yes"), Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + targetOwner, err := ctx.RequireArg("target-owner") + if err != nil { + return err + } + targetOwner = strings.TrimSpace(targetOwner) + if targetOwner == "" { + return fmt.Errorf("required flag --target-owner is missing") + } + payload := map[string]interface{}{"owner_name": targetOwner} + path := repoTransferPath(ctx, "") + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "method": "POST", + "path": path, + "payload": payload, + }) + } + if err := requireRepoTransferConfirmation(ctx, "transfer"); err != nil { + return err + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "transfer-cancel", + Description: tr.T("cmd.repo.transfer_cancel.short"), + Flags: []common.Flag{ + {Name: "dry-run", Usage: tr.T("flag.repo.transfer_cancel_dry_run"), Bool: true, Default: "false"}, + {Name: "yes", Usage: tr.T("flag.repo.transfer_yes"), Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path := repoTransferPath(ctx, "cancel") + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "method": "POST", + "path": path, + }) + } + if err := requireRepoTransferConfirmation(ctx, "transfer-cancel"); err != nil { + return err + } + env, err := ctx.CallAPI("POST", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "delete", Description: tr.T("cmd.repo.delete.short"), @@ -375,119 +481,24 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "languages", - Description: "Show language breakdown of a repository", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "contributors", - Description: "List contributors of a repository", - Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - }, - 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")) - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/contributors", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "files", - Description: "List files in a repository directory", - Flags: []common.Flag{ - {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, - {Name: "path", Short: "p", Usage: "Directory path (default: repository root)"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - if ref := ctx.Arg("ref"); ref != "" { - q.Set("ref", ref) - } - if p := ctx.Arg("path"); p != "" { - q.Set("filepath", p) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "tags", - Description: "List tags of a repository", - Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - }, - 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")) - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/tags", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "commits", - Description: "List commits of a repository", - Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: "Branch name, tag, or commit SHA"}, - {Name: "path", Short: "p", Usage: "Filter commits by file path"}, - {Name: "page", Short: "P", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - }, - 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 sha := ctx.Arg("sha"); sha != "" { - q.Set("sha", sha) - } - if p := ctx.Arg("path"); p != "" { - q.Set("path", p) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/commits", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, } } +func repoTransferPath(ctx *common.RuntimeContext, action string) string { + base := ctx.RepoPath() + "/applied_transfer_projects" + if action == "" { + return base + } + return fmt.Sprintf("%s/%s", base, action) +} + +func requireRepoTransferConfirmation(ctx *common.RuntimeContext, shortcut string) error { + if ctx.Arg("yes") == "true" { + return nil + } + return fmt.Errorf("refusing to run repo +%s without --yes; use --dry-run to preview the request first", shortcut) +} + func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] @@ -495,173 +506,6 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { return i18n.Default() } -func runFiles(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - setRepoQueryIfPresent(q, "search", ctx.Arg("search")) - setRepoQueryIfPresent(q, "ref", ctx.Arg("ref")) - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runCommits(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - q.Set("page", firstRepoValue(ctx.Arg("page"), "1")) - q.Set("limit", firstRepoValue(ctx.Arg("limit"), "20")) - setRepoQueryIfPresent(q, "sha", ctx.Arg("ref")) - env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/commits", q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runCommitFiles(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - sha, err := ctx.RequireArg("sha") - if err != nil { - return err - } - q := url.Values{} - if file := strings.TrimSpace(ctx.Arg("file")); file != "" { - q.Set("filepath", file) - } else { - q.Set("page", firstRepoValue(ctx.Arg("page"), "1")) - q.Set("limit", firstRepoValue(ctx.Arg("limit"), "20")) - } - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/commits/%s/files", ctx.RepoPath(), url.PathEscape(sha)), q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runCommitDiff(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - sha, err := ctx.RequireArg("sha") - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/commits/%s/diff", ctx.RepoPath(), url.PathEscape(sha)), nil) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runTags(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - if name := strings.TrimSpace(ctx.Arg("name")); name != "" { - q.Set("name", name) - } - onlyName := strings.TrimSpace(ctx.Arg("only-name")) - if onlyName != "" && onlyName != "false" { - q.Set("only_name", onlyName) - } - if len(q) > 0 { - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/tags", q) - if err != nil { - return err - } - return ctx.Output(env) - } - q.Set("page", firstRepoValue(ctx.Arg("page"), "1")) - q.Set("limit", firstRepoValue(ctx.Arg("limit"), "20")) - env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/tags", q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runTag(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - name, err := ctx.RequireArg("name") - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/tags/%s", ctx.RepoPath(), url.PathEscape(name)), nil) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runDeleteTag(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - name, err := ctx.RequireArg("name") - if err != nil { - return err - } - path := fmt.Sprintf("/v1%s/tags/%s", ctx.RepoPath(), url.PathEscape(name)) - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "delete_tag", - "method": "DELETE", - "path": path, - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "tag": name, - }) - } - if ctx.Arg("yes") != "true" { - return fmt.Errorf("tag deletion is destructive; run --dry-run first, then pass --yes to execute") - } - env, err := ctx.CallAPI("DELETE", path, nil) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runBatchCommit(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - payload, err := batchCommitPayload(ctx) - if err != nil { - return err - } - path := "/v1" + ctx.RepoPath() + "/contents/batch" - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "batch_commit", - "method": "POST", - "path": path, - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "payload": payload, - }) - } - if ctx.Arg("yes") != "true" { - return fmt.Errorf("batch file commit changes repository content; run --dry-run first, then pass --yes to execute") - } - env, err := ctx.CallAPI("POST", path, payload) - if err != nil { - return err - } - return ctx.Output(env) -} - func runLanguages(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -684,6 +528,44 @@ func runContributors(ctx *common.RuntimeContext) error { return ctx.Output(env) } +func runFile(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path, err := normalizeRepoFilePath(ctx) + if err != nil { + return err + } + + ref := strings.TrimSpace(ctx.Arg("ref")) + if ref == "" { + ref = "master" + } + + q := url.Values{} + q.Set("filepath", path) + q.Set("ref", ref) + + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return err + } + + entry, err := extractRepoFileEntry(env.Data, path) + if err != nil { + return err + } + if ctx.Arg("content-only") == "true" { + content, _ := entry["content"].(string) + if content == "" { + return fmt.Errorf("file response did not include content for %q", path) + } + return ctx.OutputData(content) + } + + return ctx.OutputData(buildRepoFileResult(entry, path, ref)) +} + func runContributorStats(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -725,15 +607,6 @@ func runCommunityList(ctx *common.RuntimeContext, path string) error { if err != nil { return err } - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(ctx.RepoPath()+"/"+path, q, "users") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("users", items)) - } env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/"+path, q) if err != nil { return err @@ -745,9 +618,6 @@ func communityListFlags() []common.Flag { return []common.Flag{ {Name: "start-at", Usage: "Start timestamp"}, {Name: "end-at", Usage: "End timestamp"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, } } @@ -893,101 +763,56 @@ func setRepoQueryIfPresent(q url.Values, key, value string) { } } -func firstRepoValue(value, fallback string) string { - if value := strings.TrimSpace(value); value != "" { - return value +func normalizeRepoFilePath(ctx *common.RuntimeContext) (string, error) { + path, err := ctx.RequireArg("path") + if err != nil { + return "", err } - return fallback + path = strings.TrimLeft(strings.TrimSpace(path), "/") + if path == "" { + return "", fmt.Errorf("invalid --path %q: provide a repository file path", ctx.Arg("path")) + } + return path, nil } -func batchCommitPayload(ctx *common.RuntimeContext) (map[string]interface{}, error) { - branch, err := ctx.RequireArg("branch") - if err != nil { - return nil, err +func extractRepoFileEntry(data interface{}, path string) (map[string]interface{}, error) { + payload, ok := data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected file response format") } - message, err := ctx.RequireArg("message") - if err != nil { - return nil, err + + entry, ok := payload["entries"] + if !ok { + return nil, fmt.Errorf("unexpected file response format") } - rawFiles, err := ctx.RequireArg("files") - if err != nil { - return nil, err + + if _, isDir := entry.([]interface{}); isDir { + return nil, fmt.Errorf("path %q is a directory; use repo +tree instead", path) } - encoding := firstRepoValue(ctx.Arg("encoding"), "text") - if encoding != "text" && encoding != "base64" { - return nil, fmt.Errorf("invalid --encoding %q: use text or base64", encoding) + + fileEntry, ok := entry.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected file response format") } - files, err := parseBatchFileSpecs(rawFiles, encoding) - if err != nil { - return nil, err + + if entryType, _ := fileEntry["type"].(string); entryType != "" && entryType != "file" { + return nil, fmt.Errorf("path %q is not a file; use repo +tree instead", path) } - payload := map[string]interface{}{ - "branch": strings.TrimSpace(branch), - "message": message, - "files": files, - } - if newBranch := strings.TrimSpace(ctx.Arg("new-branch")); newBranch != "" { - payload["new_branch"] = newBranch - } - setRepoPayloadIfPresent(payload, "author_name", ctx.Arg("author-name")) - setRepoPayloadIfPresent(payload, "author_email", ctx.Arg("author-email")) - setRepoPayloadIfPresent(payload, "committer_name", ctx.Arg("committer-name")) - setRepoPayloadIfPresent(payload, "committer_email", ctx.Arg("committer-email")) - return payload, nil + + return fileEntry, nil } -func parseBatchFileSpecs(raw, encoding string) ([]map[string]interface{}, error) { - specs := strings.Split(raw, ";") - files := make([]map[string]interface{}, 0, len(specs)) - for _, spec := range specs { - spec = strings.TrimSpace(spec) - if spec == "" { - continue - } - parts := strings.SplitN(spec, ":", 3) - if len(parts) < 2 { - return nil, fmt.Errorf("invalid --files item %q: use action:path[:content]", spec) - } - action := strings.TrimSpace(parts[0]) - path := strings.TrimSpace(parts[1]) - if !isBatchFileAction(action) { - return nil, fmt.Errorf("invalid file action %q: use create, update, or delete", action) - } - if path == "" { - return nil, fmt.Errorf("invalid --files item %q: file path is required", spec) - } - item := map[string]interface{}{ - "action_type": action, - "file_path": path, - } - if action != "delete" { - if len(parts) != 3 { - return nil, fmt.Errorf("file action %q for %q requires content", action, path) - } - item["content"] = parts[2] - item["encoding"] = encoding - } - files = append(files, item) +func buildRepoFileResult(entry map[string]interface{}, path, ref string) map[string]interface{} { + result := map[string]interface{}{ + "path": path, + "ref": ref, } - if len(files) == 0 { - return nil, fmt.Errorf("--files must include at least one file operation") - } - return files, nil -} - -func isBatchFileAction(action string) bool { - switch action { - case "create", "update", "delete": - return true - default: - return false - } -} - -func setRepoPayloadIfPresent(payload map[string]interface{}, key, value string) { - if value := strings.TrimSpace(value); value != "" { - payload[key] = value + for _, key := range []string{"name", "type", "size", "sha", "content"} { + if value, ok := entry[key]; ok { + result[key] = value + } } + return result } func parseOptionalRepoNonNegativeInt(value, name string) (int, bool, error) { diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index ca1594a..a9e9036 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -1,7 +1,7 @@ package search import ( - "fmt" + "encoding/json" "net/url" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -18,7 +18,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { keyword, _ := ctx.RequireArg("keyword") @@ -26,13 +25,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q.Set("search", keyword) q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey("/projects", q, "projects") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("projects", items)) - } env, err := ctx.CallAPIWithQuery("GET", "/projects", q) if err != nil { return err @@ -47,7 +39,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { keyword, _ := ctx.RequireArg("keyword") @@ -55,144 +46,29 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q.Set("search", keyword) q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey("/users/list", q, "users") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("users", items)) - } env, err := ctx.CallAPIWithQuery("GET", "/users/list", q) if err != nil { return err } return ctx.Output(env) }, - { - Name: "code", - Description: "在仓库中搜索代码", - Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "搜索关键词", Required: true}, - {Name: "owner", Usage: "仓库所有者(可选,限定范围)"}, - {Name: "repo", Usage: "仓库名称(可选,限定范围)"}, - {Name: "language", Usage: "编程语言过滤(如 go, python)"}, - {Name: "page", Short: "p", Usage: "页码", Default: "1"}, - {Name: "limit", Short: "l", Usage: "每页数量", Default: "20"}, - }, - Run: func(ctx *common.RuntimeContext) error { - keyword, err := ctx.RequireArg("keyword") - if err != nil { - return err - } - q := url.Values{} - q.Set("keyword", keyword) - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if lang := ctx.Arg("language"); lang != "" { - q.Set("language", lang) - } - path := "/search/code" - if owner := ctx.Arg("owner"); owner != "" { - if repo := ctx.Arg("repo"); repo != "" { - path = fmt.Sprintf("/%s/%s/search/code", owner, repo) - } - } - env, err := ctx.CallAPIWithQuery("GET", path, q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "issues", - Description: "搜索 Issue", - Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "搜索关键词", Required: true}, - {Name: "state", Short: "s", Usage: "状态过滤: open/closed/all", Default: "all"}, - {Name: "label", Usage: "标签过滤"}, - {Name: "author", Usage: "作者过滤"}, - {Name: "page", Short: "p", Usage: "页码", Default: "1"}, - {Name: "limit", Short: "l", Usage: "每页数量", Default: "20"}, - }, - Run: func(ctx *common.RuntimeContext) error { - keyword, err := ctx.RequireArg("keyword") - if err != nil { - return err - } - q := url.Values{} - q.Set("keyword", keyword) - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if state := ctx.Arg("state"); state != "" && state != "all" { - q.Set("state", state) - } - if label := ctx.Arg("label"); label != "" { - q.Set("label", label) - } - if author := ctx.Arg("author"); author != "" { - q.Set("author", author) - } - env, err := ctx.CallAPIWithQuery("GET", "/search/issues", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, }, { - Name: "issues", - Description: tr.T("cmd.search.issues.short"), - Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, - {Name: "category", Short: "c", Usage: tr.T("flag.search.issues.category"), Default: "all"}, - {Name: "assignee", Short: "a", Usage: tr.T("flag.search.issues.assignee")}, - {Name: "author", Usage: tr.T("flag.search.issues.author")}, - {Name: "milestone", Short: "m", Usage: tr.T("flag.search.issues.milestone")}, - {Name: "tag", Short: "t", Usage: tr.T("flag.search.issues.tag")}, - {Name: "sort-by", Usage: tr.T("flag.sort_by"), Default: "updated_on"}, - {Name: "sort-dir", Usage: tr.T("flag.sort_direction"), Default: "desc"}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - }, + Name: "recommend", + Description: tr.T("cmd.search.recommend.short"), + Long: tr.T("cmd.search.recommend.long"), Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - keyword, err := ctx.RequireArg("keyword") + env, err := ctx.CallAPI("GET", "/projects/recommend", nil) if err != nil { return err } - q := url.Values{} - q.Set("keyword", keyword) - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if c := ctx.Arg("category"); c != "" { - q.Set("category", c) - } - if a := ctx.Arg("assignee"); a != "" { - q.Set("assigner_id", a) - } - if a := ctx.Arg("author"); a != "" { - q.Set("author_id", a) - } - if m := ctx.Arg("milestone"); m != "" { - q.Set("milestone_id", m) - } - if t := ctx.Arg("tag"); t != "" { - q.Set("issue_tag_ids", t) - } - if s := ctx.Arg("sort-by"); s != "" { - q.Set("sort_by", "issues."+s) - } - if d := ctx.Arg("sort-dir"); d != "" { - q.Set("sort_direction", d) - } - env, err := ctx.CallAPIWithQuery("GET", - fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q) - if err != nil { - return err + // This endpoint returns a bare JSON array, which the client + // surfaces as a raw string; normalize it to structured data. + if s, ok := env.Data.(string); ok { + var arr interface{} + if json.Unmarshal([]byte(s), &arr) == nil { + return ctx.OutputData(arr) + } } return ctx.Output(env) }, diff --git a/shortcuts/tag/tag.go b/shortcuts/tag/tag.go deleted file mode 100644 index 885ac69..0000000 --- a/shortcuts/tag/tag.go +++ /dev/null @@ -1,86 +0,0 @@ -package tag - -import ( - "encoding/json" - "fmt" - "net/url" - - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -// Shortcuts returns git tag shortcuts. -// -// Tags previously had no first-class command even though the platform -// exposes a paginated v1 endpoint; releases only cover annotated releases, -// while lightweight tags were reachable through the raw API alone. -func Shortcuts() []*common.Shortcut { - return []*common.Shortcut{ - { - Name: "list", - Description: "List repository git tags", - Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - path := fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo) - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(path, q, "tags") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("tags", items)) - } - env, err := ctx.CallAPIWithQuery("GET", path, q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "view", - Description: "Show a git tag by name", - Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Tag name", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - name, err := ctx.RequireArg("name") - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/tags/%s", ctx.Owner, ctx.Repo, url.PathEscape(name)), nil) - if err == nil { - return ctx.Output(env) - } - // The show endpoint's tag-existence precheck is unreliable in - // production (rejects tags that the paginated list returns), - // so fall back to scanning the list for the requested name. - q := url.Values{} - q.Set("page", "1") - q.Set("limit", "20") - items, listErr := ctx.PaginateAllKey(fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo), q, "tags") - if listErr != nil { - return err - } - for _, item := range items { - var tag map[string]interface{} - if json.Unmarshal(item, &tag) == nil && tag["name"] == name { - return ctx.OutputData(tag) - } - } - return err - }, - }, - } -} diff --git a/shortcuts/tag/tag_test.go b/shortcuts/tag/tag_test.go deleted file mode 100644 index 62940b1..0000000 --- a/shortcuts/tag/tag_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package tag - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gitlink-org/gitlink-cli/internal/client" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -func TestTagList(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/tags.json" { - t.Fatalf("got request %s %s, want GET /v1/owner/repo/tags.json", r.Method, r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]interface{}{"total_count": 1, "tags": []interface{}{map[string]interface{}{"name": "v1.0.0"}}}); err != nil { - t.Fatalf("failed to write response: %v", err) - } - })) - defer server.Close() - - if err := runTagShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}); err != nil { - t.Fatalf("list shortcut failed: %v", err) - } -} - -func TestTagListAllMergesPages(t *testing.T) { - pages := map[string][]interface{}{ - "1": {map[string]interface{}{"name": "v1"}, map[string]interface{}{"name": "v2"}}, - "2": {map[string]interface{}{"name": "v3"}}, - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]interface{}{"total_count": 3, "tags": pages[r.URL.Query().Get("page")]}); err != nil { - t.Fatalf("failed to write response: %v", err) - } - })) - defer server.Close() - - if err := runTagShortcut(t, server, "list", map[string]string{"all": "true", "page": "1", "limit": "2"}); err != nil { - t.Fatalf("list --all shortcut failed: %v", err) - } -} - -func runTagShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - return shortcut.Run(ctx) - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func TestTagViewDirectShow(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/owner/repo/tags/v1.0.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"name":"v1.0"}`)) - })) - defer server.Close() - - if err := runTagShortcut(t, server, "view", map[string]string{"name": "v1.0"}); err != nil { - t.Fatalf("view failed: %v", err) - } -} - -func TestTagViewFallsBackToListScan(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/v1/owner/repo/tags/mytag.json" { - w.Write([]byte(`{"status":-1,"message":"标签不存在!"}`)) - return - } - if r.URL.Path == "/v1/owner/repo/tags.json" { - w.Write([]byte(`{"total_count":1,"tags":[{"name":"mytag","id":"abc"}]}`)) - return - } - t.Fatalf("unexpected path: %s", r.URL.Path) - })) - defer server.Close() - - if err := runTagShortcut(t, server, "view", map[string]string{"name": "mytag"}); err != nil { - t.Fatalf("view fallback failed: %v", err) - } -} diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 3f313cd..7d0574a 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -2,7 +2,6 @@ package webhook import ( "fmt" - "net/url" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -30,26 +29,11 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { { Name: "list", Description: tr.T("cmd.webhook.list.short"), - Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, - }, 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 ctx.Arg("all") == "true" { - items, err := ctx.PaginateAllKey(webhookPath(ctx), q, "webhooks") - if err != nil { - return err - } - return ctx.Output(common.NewListEnvelope("webhooks", items)) - } - env, err := ctx.CallAPIWithQuery("GET", webhookPath(ctx), q) + env, err := ctx.CallAPI("GET", webhookPath(ctx), nil) if err != nil { return err } diff --git a/shortcuts/workflow/release_notes_fetch.go b/shortcuts/workflow/release_notes_fetch.go deleted file mode 100644 index 1ead9b2..0000000 --- a/shortcuts/workflow/release_notes_fetch.go +++ /dev/null @@ -1,180 +0,0 @@ -package workflow - -import ( - "fmt" - "net/url" - "strings" - - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -type ReleaseNotesFetchOptions struct { - Owner string - Repo string - FromRef string - ToRef string - Version string - MaxCommits int - IncludePRs bool -} - -func FetchReleaseNotesInput(ctx *common.RuntimeContext, opts ReleaseNotesFetchOptions) (ReleaseNotesInput, []ScoringNote, error) { - owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo) - if err != nil { - return ReleaseNotesInput{}, nil, fmt.Errorf("workflow +release-notes remote mode requires --owner and --repo or a Git remote: %w", err) - } - fromRef := strings.TrimSpace(opts.FromRef) - if fromRef == "" { - return ReleaseNotesInput{}, nil, fmt.Errorf("workflow +release-notes remote mode requires --from-ref") - } - toRef := strings.TrimSpace(opts.ToRef) - if toRef == "" { - toRef = "master" - } - if opts.MaxCommits <= 0 { - opts.MaxCommits = 200 - } - - query := url.Values{} - query.Set("from", fromRef) - query.Set("to", toRef) - env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/compare", query) - if err != nil { - return ReleaseNotesInput{}, nil, fmt.Errorf("fetch release notes compare: %w\nhint: use --from release_notes.json for local release notes generation", err) - } - - commits := releaseNotesCommitsFromData(env.Data, opts.MaxCommits) - prs := []ReleaseNotesPR{} - if opts.IncludePRs { - prs = releaseNotesPRsFromData(env.Data) - } - input := ReleaseNotesInput{ - Repository: fmt.Sprintf("%s/%s", owner, repo), - Version: strings.TrimSpace(opts.Version), - FromRef: fromRef, - ToRef: toRef, - PullRequests: prs, - Commits: commits, - Source: "remote-read-only-fetch", - } - notes := []ScoringNote{} - if len(commits) == opts.MaxCommits { - notes = append(notes, ScoringNote{Metric: "release_notes_commits", Note: fmt.Sprintf("commit list truncated to %d entries", opts.MaxCommits)}) - } - if len(commits) == 0 && len(prs) == 0 { - notes = append(notes, ScoringNote{Metric: "release_notes_compare", Note: "compare response contained no commits or pull requests"}) - } - return input, uniqueScoringNotes(notes), nil -} - -func releaseNotesCommitsFromData(data interface{}, limit int) []ReleaseNotesCommit { - items := releaseNotesListFromData(data, []string{"commits", "commit_list"}) - commits := make([]ReleaseNotesCommit, 0, len(items)) - for _, raw := range items { - commit, ok := normalizeReleaseNotesCommit(raw) - if !ok { - continue - } - commits = append(commits, commit) - if limit > 0 && len(commits) >= limit { - break - } - } - return commits -} - -func releaseNotesPRsFromData(data interface{}) []ReleaseNotesPR { - items := releaseNotesListFromData(data, []string{"pull_requests", "pulls", "prs", "merge_requests"}) - prs := make([]ReleaseNotesPR, 0, len(items)) - for _, raw := range items { - pr, ok := normalizeReleaseNotesPR(raw) - if !ok { - continue - } - prs = append(prs, pr) - } - return prs -} - -func releaseNotesListFromData(data interface{}, keys []string) []interface{} { - normalized, err := normalizeAPIData(data) - if err != nil { - return nil - } - switch value := normalized.(type) { - case []interface{}: - return value - case map[string]interface{}: - for _, key := range keys { - if raw, ok := value[key]; ok { - if items := apiList(raw); len(items) > 0 { - return items - } - } - } - for _, key := range []string{"data", "compare", "result"} { - if raw, ok := value[key]; ok { - if items := releaseNotesListFromData(raw, keys); len(items) > 0 { - return items - } - } - } - } - return nil -} - -func normalizeReleaseNotesCommit(raw interface{}) (ReleaseNotesCommit, bool) { - item, ok := raw.(map[string]interface{}) - if !ok { - return ReleaseNotesCommit{}, false - } - sha := firstPRString(item, "sha", "id") - message := firstPRString(item, "message", "title", "subject") - if strings.TrimSpace(sha) == "" && strings.TrimSpace(message) == "" { - return ReleaseNotesCommit{}, false - } - return ReleaseNotesCommit{ - SHA: sha, - Message: firstLine(message), - Author: firstPRCommitAuthor(item), - URL: firstPRString(item, "html_url", "url"), - Files: releaseNotesFiles(item), - }, true -} - -func normalizeReleaseNotesPR(raw interface{}) (ReleaseNotesPR, bool) { - item, ok := raw.(map[string]interface{}) - if !ok { - return ReleaseNotesPR{}, false - } - number := firstPRInt(item, "number", "iid", "pull_request_number") - title := firstPRString(item, "title", "subject") - if number == 0 && strings.TrimSpace(title) == "" { - return ReleaseNotesPR{}, false - } - return ReleaseNotesPR{ - Number: number, - Title: title, - Author: firstPRAuthor(item), - URL: firstPRString(item, "html_url", "url"), - Files: releaseNotesFiles(item), - }, true -} - -func releaseNotesFiles(item map[string]interface{}) []string { - rawItems := apiList(item["files"]) - files := make([]string, 0, len(rawItems)) - for _, raw := range rawItems { - switch typed := raw.(type) { - case string: - if strings.TrimSpace(typed) != "" { - files = append(files, typed) - } - case map[string]interface{}: - if file := firstPRString(typed, "filename", "file", "path", "new_path"); file != "" { - files = append(files, file) - } - } - } - return files -} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index b72f621..696cfa9 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -7,7 +7,6 @@ import ( "strconv" "strings" - "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -20,63 +19,63 @@ type TriageReport struct { Results []TriageResult `json:"results"` } -func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := shortcutTranslator(translators...) +func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ - newTriageShortcut(tr), - newHealthShortcut(tr), - newPRSummaryShortcut(tr), - newRepoReportShortcut(tr), + newTriageShortcut(), + newHealthShortcut(), + newPRSummaryShortcut(nil), + newRepoReportShortcut(), + newReviewContextShortcut(), } } -func newTriageShortcut(tr *i18n.Translator) *common.Shortcut { +func newTriageShortcut() *common.Shortcut { return &common.Shortcut{ Name: "triage", - Description: tr.T("cmd.workflow.triage.short"), + Description: "Analyze issues with local workflow triage rules", Flags: []common.Flag{ - {Name: "from", Usage: tr.T("flag.workflow.from")}, - {Name: "title", Short: "t", Usage: tr.T("flag.workflow.title")}, - {Name: "body", Short: "b", Usage: tr.T("flag.workflow.body")}, - {Name: "number", Short: "n", Usage: tr.T("flag.workflow.number")}, - {Name: "author", Usage: tr.T("flag.workflow.author")}, - {Name: "url", Usage: tr.T("flag.workflow.url")}, - {Name: "labels", Usage: tr.T("flag.workflow.labels")}, - {Name: "state", Short: "s", Usage: tr.T("flag.workflow.state"), Default: "open"}, - {Name: "page", Short: "p", Usage: tr.T("flag.workflow.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.workflow.limit"), Default: "30"}, - {Name: "since", Usage: tr.T("flag.workflow.since")}, - {Name: "dry-run", Usage: tr.T("flag.workflow.dry_run"), Bool: true, Default: "true"}, - {Name: "lang", Usage: tr.T("flag.workflow.lang"), Default: langEN}, + {Name: "from", Usage: "Read issue inputs from a JSON file. Supports a single issue, an array, or an object with an issues field"}, + {Name: "title", Short: "t", Usage: "Issue title for single-issue local analysis"}, + {Name: "body", Short: "b", Usage: "Issue body for single-issue local analysis"}, + {Name: "number", Short: "n", Usage: "Issue number for single-issue local analysis"}, + {Name: "author", Usage: "Issue author for single-issue local analysis"}, + {Name: "url", Usage: "Issue URL for single-issue local analysis"}, + {Name: "labels", Usage: "Comma-separated labels for single-issue local analysis"}, + {Name: "state", Short: "s", Usage: "Filter or assign issue state", Default: "open"}, + {Name: "page", Short: "p", Usage: "API page number for remote triage", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Maximum issues to analyze", Default: "30"}, + {Name: "since", Usage: "Optional remote issue filter for updated time"}, + {Name: "dry-run", Usage: "Preview workflow recommendations without remote writes", Bool: true, Default: "true"}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, }, Run: runTriage, } } -func newHealthShortcut(tr *i18n.Translator) *common.Shortcut { +func newHealthShortcut() *common.Shortcut { return &common.Shortcut{ Name: "health", - Description: tr.T("cmd.workflow.health.short"), + Description: "Score repository health with local workflow rules", Flags: []common.Flag{ - {Name: "from", Usage: tr.T("flag.workflow.from_2")}, - {Name: "repository", Usage: tr.T("flag.workflow.repository")}, - {Name: "open-issues", Usage: tr.T("flag.workflow.open_issues"), Default: "0"}, - {Name: "open-prs", Usage: tr.T("flag.workflow.open_prs"), Default: "0"}, - {Name: "stale-issues", Usage: tr.T("flag.workflow.stale_issues"), Default: "0"}, - {Name: "stale-prs", Usage: tr.T("flag.workflow.stale_prs"), Default: "0"}, - {Name: "recent-activity-known", Usage: tr.T("flag.workflow.recent_activity_known"), Bool: true, Default: "false"}, - {Name: "recent-activity-days", Usage: tr.T("flag.workflow.recent_activity_days"), Default: "0"}, - {Name: "release-known", Usage: tr.T("flag.workflow.release_known"), Bool: true, Default: "false"}, - {Name: "has-recent-release", Usage: tr.T("flag.workflow.has_recent_release"), Bool: true, Default: "false"}, - {Name: "ci-known", Usage: tr.T("flag.workflow.ci_known"), Bool: true, Default: "false"}, - {Name: "ci-passing", Usage: tr.T("flag.workflow.ci_passing"), Bool: true, Default: "false"}, - {Name: "has-readme", Usage: tr.T("flag.workflow.has_readme"), Bool: true, Default: "false"}, - {Name: "has-license", Usage: tr.T("flag.workflow.has_license"), Bool: true, Default: "false"}, - {Name: "has-contributing", Usage: tr.T("flag.workflow.has_contributing"), Bool: true, Default: "false"}, - {Name: "agent-readiness-known", Usage: tr.T("flag.workflow.agent_readiness_known"), Bool: true, Default: "false"}, - {Name: "agent-readiness-score", Usage: tr.T("flag.workflow.agent_readiness_score"), Default: "0"}, - {Name: "stale-days", Usage: tr.T("flag.workflow.stale_days"), Default: "30"}, - {Name: "lang", Usage: tr.T("flag.workflow.lang"), Default: langEN}, + {Name: "from", Usage: "Read health input from a JSON file"}, + {Name: "repository", Usage: "Repository name, for example owner/repo"}, + {Name: "open-issues", Usage: "Open issue count", Default: "0"}, + {Name: "open-prs", Usage: "Open pull request count", Default: "0"}, + {Name: "stale-issues", Usage: "Stale issue count", Default: "0"}, + {Name: "stale-prs", Usage: "Stale pull request count", Default: "0"}, + {Name: "recent-activity-known", Usage: "Whether recent activity is known", Bool: true, Default: "false"}, + {Name: "recent-activity-days", Usage: "Days since recent activity", Default: "0"}, + {Name: "release-known", Usage: "Whether release status is known", Bool: true, Default: "false"}, + {Name: "has-recent-release", Usage: "Whether a recent release exists", Bool: true, Default: "false"}, + {Name: "ci-known", Usage: "Whether CI status is known", Bool: true, Default: "false"}, + {Name: "ci-passing", Usage: "Whether CI is passing", Bool: true, Default: "false"}, + {Name: "has-readme", Usage: "Whether README exists", Bool: true, Default: "false"}, + {Name: "has-license", Usage: "Whether LICENSE exists", Bool: true, Default: "false"}, + {Name: "has-contributing", Usage: "Whether CONTRIBUTING exists", Bool: true, Default: "false"}, + {Name: "agent-readiness-known", Usage: "Whether agent readiness score is known", Bool: true, Default: "false"}, + {Name: "agent-readiness-score", Usage: "Agent readiness score from 0 to 10", Default: "0"}, + {Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, }, Run: runHealth, } @@ -386,10 +385,3 @@ func mustParseInt(value string, defaultValue int) int { } return parsed } - -func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { - if len(translators) > 0 && translators[0] != nil { - return translators[0] - } - return i18n.Default() -}