commit d44e6d049c205a47f3bd74434d773f41091f0593 Author: 小橘 Date: Wed Apr 1 13:14:17 2026 +0000 feat: initial implementation of gitee-cli - auth: login/logout/status with Personal Access Token - repo: list/create/view/clone/delete - issue: list/create/view/close/comment - pr: list/create/view/merge/close - release: list/create - org: list - api: raw API call with --field/--query/--paginate Features: - TypeScript + ESM - commander.js CLI parsing - Node built-in fetch (no axios) - Auto-detect owner/repo from git remote (gitee.com) - GITEE_TOKEN env var priority over config file - --json flag for machine-readable output - --page/--per-page pagination support - Friendly error messages (401/403/404) - Config stored at ~/.config/gitee-cli/config.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e9eee0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.js.map diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..5fcd9f8 --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +src/ +*.ts +tsconfig.json +.gitignore diff --git a/README.md b/README.md new file mode 100644 index 0000000..56225c3 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# gitee-cli + +**Gitee (码云) 命令行工具** — 类似 `gh`,但面向 Gitee。 + +A command-line tool for Gitee (码云) — like `gh`, but for Gitee. + +[![npm version](https://img.shields.io/npm/v/gitee-cli.svg)](https://www.npmjs.com/package/gitee-cli) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +--- + +## 安装 / Installation + +### 全局安装 (Recommended) + +```bash +npm install -g gitee-cli +``` + +### 从源码构建 / Build from source + +```bash +git clone https://github.com/shazhou-ww/gitee-cli.git +cd gitee-cli +npm install +npm run build +npm link +``` + +--- + +## 认证 / Authentication + +gitee-cli 使用 Gitee Personal Access Token 认证。 + +1. 前往 https://gitee.com/profile/personal_access_tokens 创建 Token +2. 运行登录命令 + +```bash +gitee auth login +``` + +### 环境变量 / Environment Variable + +`GITEE_TOKEN` 优先级高于配置文件: + +```bash +export GITEE_TOKEN=your_token_here +``` + +### 命令 / Commands + +```bash +gitee auth login # 交互式登录 +gitee auth logout # 清除认证 +gitee auth status # 查看当前认证状态 +``` + +--- + +## 命令列表 / Commands + +### 仓库 / Repository + +```bash +gitee repo list # 列出我的仓库 +gitee repo list --owner # 列出指定用户的仓库 +gitee repo list --type private # 只列出私有仓库 +gitee repo create # 创建仓库 +gitee repo create --private # 创建私有仓库 +gitee repo create --description "..." +gitee repo view # 查看当前仓库 (自动检测) +gitee repo view # 查看指定仓库 +gitee repo clone # Clone 仓库 +gitee repo delete # 删除仓库 (需确认) +``` + +### Issue + +```bash +gitee issue list # 列出 issues (当前仓库) +gitee issue list --repo # 指定仓库 +gitee issue list --state closed # 列出已关闭的 issues +gitee issue create --title "Bug fix" # 创建 issue +gitee issue create --title "..." --body "..." --repo +gitee issue view # 查看 issue 详情 +gitee issue close # 关闭 issue +gitee issue comment --body "..." # 评论 +``` + +### Pull Request + +```bash +gitee pr list # 列出 PRs (当前仓库) +gitee pr list --state merged # 已合并的 PRs +gitee pr create --title "feat: xxx" --head feature-branch +gitee pr create --title "..." --head --base master --body "..." +gitee pr view # 查看 PR 详情 +gitee pr merge # 合并 PR +gitee pr merge --method squash # Squash 合并 +gitee pr close # 关闭 PR +``` + +### Release + +```bash +gitee release list # 列出 releases +gitee release list --repo +gitee release create --tag v1.0.0 --name "v1.0.0 Release" +gitee release create --tag v1.0.0 --name "..." --body "Release notes" +``` + +### 组织 / Organization + +```bash +gitee org list # 列出我加入的组织 +``` + +### 通用 API / Raw API + +```bash +gitee api GET /v5/emojis # 不需要认证 +gitee api GET /v5/user # 获取当前用户 +gitee api GET /v5/repos/owner/repo +gitee api POST /v5/user/repos --field name=myrepo --field private=true +gitee api GET /v5/repos/owner/repo/issues --query state=open +gitee api GET /v5/repos/owner/repo/issues --paginate # 自动翻页 +``` + +--- + +## 通用选项 / Global Options + +| 选项 | 说明 | +|------|------| +| `--json` | 输出原始 JSON(方便脚本/AI 解析) | +| `--repo ` | 指定仓库(省略时自动检测 git remote) | +| `--page ` | 分页页码(默认 1) | +| `--per-page ` | 每页条数(默认 20,最大 100) | + +--- + +## 自动检测仓库 / Auto-detect Repository + +当你在一个 Gitee 仓库目录内运行命令时,`--repo` 参数可以省略,gitee-cli 会自动从 `git remote` 检测 `owner/repo`。 + +支持两种 remote 格式: +- `https://gitee.com/owner/repo.git` +- `git@gitee.com:owner/repo.git` + +--- + +## 配置文件 / Config File + +Token 存储在 `~/.config/gitee-cli/config.json`: + +```json +{ + "token": "your_token", + "username": "your_username" +} +``` + +--- + +## 技术栈 / Tech Stack + +- Node.js 18+ (ESM) +- TypeScript +- commander.js +- Node 内置 `fetch` (无 axios 依赖) + +--- + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2e4f0d9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,609 @@ +{ + "name": "gitee-cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitee-cli", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0" + }, + "bin": { + "gitee": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsx": "^4.15.7", + "typescript": "^5.5.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e479b13 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "gitee-cli", + "version": "0.1.0", + "description": "Gitee (码云) command-line tool — like gh, but for Gitee", + "type": "module", + "bin": { + "gitee": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/cli.ts", + "prepublishOnly": "npm run build" + }, + "keywords": ["gitee", "cli", "git", "vcs"], + "author": "小橘", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsx": "^4.15.7", + "typescript": "^5.5.2" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..a6efc5f --- /dev/null +++ b/src/api.ts @@ -0,0 +1,141 @@ +import { execSync } from 'child_process'; + +const API_BASE = 'https://gitee.com/api/v5'; + +export interface RequestOptions { + method?: string; + params?: Record; + body?: Record; + token?: string; +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string + ) { + super(message); + this.name = 'ApiError'; + } +} + +export async function apiRequest( + path: string, + options: RequestOptions = {} +): Promise { + const { method = 'GET', params = {}, body, token } = options; + + // Build URL + const url = new URL(path.startsWith('http') ? path : `${API_BASE}${path}`); + + // Add token to params if available + if (token) { + url.searchParams.set('access_token', token); + } + + // Add other query params + for (const [key, val] of Object.entries(params)) { + if (val !== undefined && val !== null) { + url.searchParams.set(key, String(val)); + } + } + + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'gitee-cli/0.1.0', + }; + + const fetchOptions: RequestInit = { + method, + headers, + }; + + if (body && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) { + fetchOptions.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), fetchOptions); + + if (!response.ok) { + let message = `HTTP ${response.status} ${response.statusText}`; + try { + const errBody = await response.json() as Record; + if (errBody.message) { + message = String(errBody.message); + } else if (errBody.error) { + message = String(errBody.error); + } + } catch { + // ignore + } + throw new ApiError(response.status, message); + } + + // 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} + +/** + * Detect owner/repo from current git remote (gitee.com) + */ +export function detectRepo(): string | undefined { + try { + const remote = execSync('git remote get-url origin 2>/dev/null', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + // Match: https://gitee.com/owner/repo or git@gitee.com:owner/repo.git + const httpsMatch = remote.match(/https:\/\/gitee\.com\/([^/]+\/[^/]+?)(?:\.git)?$/); + const sshMatch = remote.match(/git@gitee\.com:([^/]+\/[^/]+?)(?:\.git)?$/); + + return (httpsMatch || sshMatch)?.[1]; + } catch { + return undefined; + } +} + +/** + * Resolve owner/repo: explicit arg > auto-detect > error + */ +export function resolveRepo(explicit?: string): string { + if (explicit) return explicit; + const detected = detectRepo(); + if (detected) { + return detected; + } + console.error('Error: Could not determine repository. Use --repo or run inside a gitee.com git directory.'); + process.exit(1); +} + +export function formatDate(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleDateString('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }); +} + +export function handleError(err: unknown): never { + if (err instanceof ApiError) { + if (err.status === 401) { + console.error('Error: Unauthorized. Please run `gitee auth login` first or set GITEE_TOKEN.'); + } else if (err.status === 404) { + console.error('Error: Not found. Check repository name and your access permissions.'); + } else if (err.status === 403) { + console.error('Error: Forbidden. You may not have permission for this operation.'); + } else { + console.error(`Error: ${err.message}`); + } + } else if (err instanceof Error) { + console.error(`Error: ${err.message}`); + } else { + console.error('Unknown error occurred'); + } + process.exit(1); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..4e2a3b4 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import { registerAuthCommands } from './commands/auth.js'; +import { registerRepoCommands } from './commands/repo.js'; +import { registerIssueCommands } from './commands/issue.js'; +import { registerPrCommands } from './commands/pr.js'; +import { registerReleaseCommands } from './commands/release.js'; +import { registerOrgCommands } from './commands/org.js'; +import { registerApiCommand } from './commands/api.js'; + +const program = new Command(); + +program + .name('gitee') + .description('Gitee (码云) command-line tool — like gh, but for Gitee') + .version('0.1.0'); + +registerAuthCommands(program); +registerRepoCommands(program); +registerIssueCommands(program); +registerPrCommands(program); +registerReleaseCommands(program); +registerOrgCommands(program); +registerApiCommand(program); + +program.parse(process.argv); diff --git a/src/commands/api.ts b/src/commands/api.ts new file mode 100644 index 0000000..c84e4cd --- /dev/null +++ b/src/commands/api.ts @@ -0,0 +1,99 @@ +import { Command } from 'commander'; +import { getToken } from '../config.js'; +import { apiRequest, handleError } from '../api.js'; + +export function registerApiCommand(program: Command): void { + program + .command('api ') + .description('Make a raw API call to Gitee API v5') + .option('--field ', 'Set request field (repeatable)', (val: string, prev: string[]) => { + prev.push(val); + return prev; + }, [] as string[]) + .option('--query ', 'Set query parameter (repeatable)', (val: string, prev: string[]) => { + prev.push(val); + return prev; + }, [] as string[]) + .option('--no-auth', 'Skip authentication token') + .option('--paginate', 'Fetch all pages and combine results') + .action(async (method: string, path: string, opts: { + field: string[]; + query: string[]; + auth: boolean; + paginate?: boolean; + }) => { + const token = opts.auth !== false ? getToken() : undefined; + + // Parse fields into body + const body: Record = {}; + for (const f of opts.field) { + const idx = f.indexOf('='); + if (idx === -1) { + console.error(`Error: Invalid field format "${f}". Use key=value.`); + process.exit(1); + } + body[f.slice(0, idx)] = f.slice(idx + 1); + } + + // Parse query params + const params: Record = {}; + for (const q of opts.query) { + const idx = q.indexOf('='); + if (idx === -1) { + console.error(`Error: Invalid query format "${q}". Use key=value.`); + process.exit(1); + } + params[q.slice(0, idx)] = q.slice(idx + 1); + } + + // Normalize path + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + try { + const requestOpts = { + method: method.toUpperCase(), + token, + params: Object.keys(params).length > 0 ? params : undefined, + body: ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) && Object.keys(body).length > 0 ? body : undefined, + }; + + // Resolve the API path: + // - If user passes /v5/... → use as-is (strip leading /v5 since API_BASE already has /v5) + // - If user passes /repos/... or /user/... → prepend nothing (API_BASE handles it) + // - Raw absolute URL → pass directly + let apiPath: string; + if (normalizedPath.startsWith('/v5/')) { + // strip /v5 prefix since apiRequest prepends API_BASE which already ends with /v5 + apiPath = normalizedPath.slice(3); // remove /v5 + } else { + apiPath = normalizedPath; + } + + if (opts.paginate) { + const allResults: unknown[] = []; + let page = 1; + while (true) { + const pageParams = { ...params, page, per_page: 100 }; + const result = await apiRequest(apiPath, { + ...requestOpts, + params: pageParams, + }); + if (Array.isArray(result)) { + allResults.push(...result); + if (result.length < 100) break; + page++; + } else { + console.log(JSON.stringify(result, null, 2)); + return; + } + } + console.log(JSON.stringify(allResults, null, 2)); + } else { + const result = await apiRequest(apiPath, requestOpts); + console.log(JSON.stringify(result, null, 2)); + } + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..b5adb78 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,141 @@ +import { Command } from 'commander'; +import * as readline from 'readline'; +import { getConfig, saveConfig, clearConfig, getToken } from '../config.js'; +import { apiRequest, handleError, ApiError } from '../api.js'; + +interface GiteeUser { + login: string; + name: string; + email?: string; + avatar_url?: string; + bio?: string; + public_repos?: number; + followers?: number; + following?: number; +} + +function prompt(question: string, hidden = false): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + if (hidden) { + // Hide input for token + process.stdout.write(question); + let value = ''; + const stdin = process.stdin; + stdin.setRawMode?.(true); + stdin.resume(); + stdin.setEncoding('utf-8'); + stdin.on('data', (char: string) => { + if (char === '\n' || char === '\r' || char === '\u0003') { + stdin.setRawMode?.(false); + stdin.pause(); + process.stdout.write('\n'); + rl.close(); + resolve(value); + } else if (char === '\u007F') { + if (value.length > 0) { + value = value.slice(0, -1); + process.stdout.write('\b \b'); + } + } else { + value += char; + process.stdout.write('*'); + } + }); + } else { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + } + }); +} + +export function registerAuthCommands(program: Command): void { + const auth = program + .command('auth') + .description('Manage authentication'); + + auth + .command('login') + .description('Authenticate with a Gitee Personal Access Token') + .option('--with-token', 'Read token from stdin (non-interactive)') + .action(async (opts: { withToken?: boolean }) => { + let token: string; + + if (opts.withToken) { + // Read from stdin + const chunks: string[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + token = chunks.join('').trim(); + } else { + console.log('Gitee CLI Authentication'); + console.log('Get your token at: https://gitee.com/profile/personal_access_tokens'); + console.log(''); + token = await prompt('Enter your Gitee Personal Access Token: ', true); + } + + if (!token) { + console.error('Error: Token cannot be empty.'); + process.exit(1); + } + + // Verify the token + console.log('Verifying token...'); + try { + const user = await apiRequest('/user', { token }); + saveConfig({ token, username: user.login }); + console.log(`✓ Logged in as ${user.login} (${user.name || user.login})`); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + console.error('Error: Invalid token. Please check your Personal Access Token.'); + process.exit(1); + } + handleError(err); + } + }); + + auth + .command('logout') + .description('Clear stored authentication credentials') + .action(() => { + clearConfig(); + console.log('✓ Logged out. Token cleared.'); + }); + + auth + .command('status') + .description('Show current authentication status') + .action(async () => { + const token = getToken(); + if (!token) { + console.log('Not logged in.'); + console.log('Run `gitee auth login` to authenticate.'); + return; + } + + const fromEnv = !!process.env.GITEE_TOKEN; + const config = getConfig(); + + console.log(`Token source: ${fromEnv ? 'GITEE_TOKEN (env)' : 'config file'}`); + if (config.username) { + console.log(`Cached username: ${config.username}`); + } + + console.log('Verifying token...'); + try { + const user = await apiRequest('/user', { token }); + console.log(`✓ Authenticated as ${user.login} (${user.name || user.login})`); + if (user.email) console.log(`Email: ${user.email}`); + if (user.public_repos !== undefined) console.log(`Public repos: ${user.public_repos}`); + } catch { + console.log('✗ Token is invalid or expired.'); + } + }); +} diff --git a/src/commands/issue.ts b/src/commands/issue.ts new file mode 100644 index 0000000..56d5c4c --- /dev/null +++ b/src/commands/issue.ts @@ -0,0 +1,215 @@ +import { Command } from 'commander'; +import { getToken } from '../config.js'; +import { apiRequest, resolveRepo, formatDate, handleError } from '../api.js'; + +interface GiteeIssue { + id: number; + number: string; + title: string; + state: string; + body?: string; + user?: { login: string }; + assignee?: { login: string }; + labels?: Array<{ name: string; color: string }>; + comments?: number; + created_at: string; + updated_at: string; + html_url: string; +} + +export function registerIssueCommands(program: Command): void { + const issue = program + .command('issue') + .description('Manage issues'); + + issue + .command('list') + .description('List issues in a repository') + .option('--repo ', 'Repository (owner/repo)') + .option('--state ', 'State: open|closed|all (default: open)', 'open') + .option('--page ', 'Page number', '1') + .option('--per-page ', 'Results per page', '20') + .option('--json', 'Output raw JSON') + .action(async (opts: { repo?: string; state?: string; page?: string; perPage?: string; json?: boolean }) => { + const token = getToken(); + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const issues = await apiRequest(`/repos/${owner}/${repo}/issues`, { + token, + params: { + state: opts.state || 'open', + page: opts.page || 1, + per_page: opts.perPage || 20, + }, + }); + + if (opts.json) { + console.log(JSON.stringify(issues, null, 2)); + return; + } + + if (!issues.length) { + console.log('No issues found.'); + return; + } + + console.log(`Issues in ${repoName}:\n`); + for (const issue of issues) { + const labels = issue.labels?.map(l => `[${l.name}]`).join(' ') || ''; + const comments = issue.comments ? `💬 ${issue.comments}` : ''; + console.log(` #${issue.number} ${issue.title} ${labels}`); + console.log(` ${issue.state} · by ${issue.user?.login || 'unknown'} · ${formatDate(issue.created_at)} ${comments}`); + } + } catch (err) { + handleError(err); + } + }); + + issue + .command('create') + .description('Create an issue') + .option('--repo ', 'Repository (owner/repo)') + .requiredOption('--title ', 'Issue title') + .option('--body <body>', 'Issue body/description') + .option('--assignee <username>', 'Assign to user') + .option('--json', 'Output raw JSON') + .action(async (opts: { repo?: string; title: string; body?: string; assignee?: string; json?: boolean }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const created = await apiRequest<GiteeIssue>(`/repos/${owner}/${repo}/issues`, { + method: 'POST', + token, + body: { + title: opts.title, + body: opts.body || '', + assignee: opts.assignee, + }, + }); + + if (opts.json) { + console.log(JSON.stringify(created, null, 2)); + return; + } + + console.log(`✓ Created issue #${created.number}: ${created.title}`); + console.log(` URL: ${created.html_url}`); + } catch (err) { + handleError(err); + } + }); + + issue + .command('view <number>') + .description('View issue details') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .option('--json', 'Output raw JSON') + .action(async (number: string, opts: { repo?: string; json?: boolean }) => { + const token = getToken(); + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const iss = await apiRequest<GiteeIssue>(`/repos/${owner}/${repo}/issues/${number}`, { token }); + + if (opts.json) { + console.log(JSON.stringify(iss, null, 2)); + return; + } + + console.log(`#${iss.number} ${iss.title}`); + console.log(`─────────────────────────────`); + console.log(`State: ${iss.state}`); + console.log(`Author: ${iss.user?.login || 'unknown'}`); + if (iss.assignee) console.log(`Assignee: ${iss.assignee.login}`); + if (iss.labels?.length) console.log(`Labels: ${iss.labels.map(l => l.name).join(', ')}`); + console.log(`Created: ${formatDate(iss.created_at)}`); + console.log(`Updated: ${formatDate(iss.updated_at)}`); + console.log(`URL: ${iss.html_url}`); + if (iss.body) { + console.log(`\n${iss.body}`); + } + } catch (err) { + handleError(err); + } + }); + + issue + .command('close <number>') + .description('Close an issue') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .option('--json', 'Output raw JSON') + .action(async (number: string, opts: { repo?: string; json?: boolean }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const updated = await apiRequest<GiteeIssue>(`/repos/${owner}/${repo}/issues/${number}`, { + method: 'PATCH', + token, + body: { state: 'closed', repo }, + }); + + if (opts.json) { + console.log(JSON.stringify(updated, null, 2)); + return; + } + + console.log(`✓ Closed issue #${number}`); + } catch (err) { + handleError(err); + } + }); + + issue + .command('comment <number>') + .description('Add a comment to an issue') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .requiredOption('--body <comment>', 'Comment body') + .option('--json', 'Output raw JSON') + .action(async (number: string, opts: { repo?: string; body: string; json?: boolean }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const comment = await apiRequest<{ id: number; body: string; created_at: string }>( + `/repos/${owner}/${repo}/issues/${number}/comments`, + { + method: 'POST', + token, + body: { body: opts.body }, + } + ); + + if (opts.json) { + console.log(JSON.stringify(comment, null, 2)); + return; + } + + console.log(`✓ Comment added (id: ${comment.id})`); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/org.ts b/src/commands/org.ts new file mode 100644 index 0000000..1f4de6e --- /dev/null +++ b/src/commands/org.ts @@ -0,0 +1,62 @@ +import { Command } from 'commander'; +import { getToken } from '../config.js'; +import { apiRequest, handleError } from '../api.js'; + +interface GiteeOrg { + id: number; + login: string; + name?: string; + avatar_url?: string; + description?: string; + html_url?: string; + public_repos?: number; + members_count?: number; +} + +export function registerOrgCommands(program: Command): void { + const org = program + .command('org') + .description('Manage organizations'); + + org + .command('list') + .description('List organizations for the authenticated user') + .option('--page <n>', 'Page number', '1') + .option('--per-page <n>', 'Results per page', '20') + .option('--json', 'Output raw JSON') + .action(async (opts: { page?: string; perPage?: string; json?: boolean }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + try { + const orgs = await apiRequest<GiteeOrg[]>('/user/orgs', { + token, + params: { + page: opts.page || 1, + per_page: opts.perPage || 20, + }, + }); + + if (opts.json) { + console.log(JSON.stringify(orgs, null, 2)); + return; + } + + if (!orgs.length) { + console.log('No organizations found.'); + return; + } + + console.log(`Organizations:\n`); + for (const o of orgs) { + console.log(` ${o.login}${o.name && o.name !== o.login ? ` (${o.name})` : ''}`); + if (o.description) console.log(` ${o.description}`); + } + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/pr.ts b/src/commands/pr.ts new file mode 100644 index 0000000..aba2ebe --- /dev/null +++ b/src/commands/pr.ts @@ -0,0 +1,233 @@ +import { Command } from 'commander'; +import { getToken } from '../config.js'; +import { apiRequest, resolveRepo, formatDate, handleError } from '../api.js'; + +interface GiteePR { + id: number; + number: number; + title: string; + state: string; + body?: string; + user?: { login: string }; + head?: { label: string; sha: string }; + base?: { label: string; sha: string }; + merged?: boolean; + merged_at?: string; + created_at: string; + updated_at: string; + html_url: string; + comments?: number; + commits?: number; + changed_files?: number; +} + +export function registerPrCommands(program: Command): void { + const pr = program + .command('pr') + .description('Manage pull requests'); + + pr + .command('list') + .description('List pull requests') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .option('--state <state>', 'State: open|closed|merged|all (default: open)', 'open') + .option('--page <n>', 'Page number', '1') + .option('--per-page <n>', 'Results per page', '20') + .option('--json', 'Output raw JSON') + .action(async (opts: { repo?: string; state?: string; page?: string; perPage?: string; json?: boolean }) => { + const token = getToken(); + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const prs = await apiRequest<GiteePR[]>(`/repos/${owner}/${repo}/pulls`, { + token, + params: { + state: opts.state || 'open', + page: opts.page || 1, + per_page: opts.perPage || 20, + }, + }); + + if (opts.json) { + console.log(JSON.stringify(prs, null, 2)); + return; + } + + if (!prs.length) { + console.log('No pull requests found.'); + return; + } + + console.log(`Pull requests in ${repoName}:\n`); + for (const p of prs) { + const head = p.head?.label || 'unknown'; + const base = p.base?.label || 'unknown'; + console.log(` #${p.number} ${p.title}`); + console.log(` ${p.state} · ${head} → ${base} · by ${p.user?.login || 'unknown'} · ${formatDate(p.created_at)}`); + } + } catch (err) { + handleError(err); + } + }); + + pr + .command('create') + .description('Create a pull request') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .requiredOption('--title <title>', 'PR title') + .requiredOption('--head <branch>', 'Source branch (head)') + .option('--base <branch>', 'Target branch (base)', 'master') + .option('--body <desc>', 'PR description') + .option('--json', 'Output raw JSON') + .action(async (opts: { repo?: string; title: string; head: string; base?: string; body?: string; json?: boolean }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const created = await apiRequest<GiteePR>(`/repos/${owner}/${repo}/pulls`, { + method: 'POST', + token, + body: { + title: opts.title, + head: opts.head, + base: opts.base || 'master', + body: opts.body || '', + }, + }); + + if (opts.json) { + console.log(JSON.stringify(created, null, 2)); + return; + } + + console.log(`✓ Created PR #${created.number}: ${created.title}`); + console.log(` URL: ${created.html_url}`); + } catch (err) { + handleError(err); + } + }); + + pr + .command('view <number>') + .description('View pull request details') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .option('--json', 'Output raw JSON') + .action(async (number: string, opts: { repo?: string; json?: boolean }) => { + const token = getToken(); + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const p = await apiRequest<GiteePR>(`/repos/${owner}/${repo}/pulls/${number}`, { token }); + + if (opts.json) { + console.log(JSON.stringify(p, null, 2)); + return; + } + + console.log(`#${p.number} ${p.title}`); + console.log(`─────────────────────────────`); + console.log(`State: ${p.state}${p.merged ? ' (merged)' : ''}`); + console.log(`Author: ${p.user?.login || 'unknown'}`); + console.log(`Head: ${p.head?.label || 'unknown'}`); + console.log(`Base: ${p.base?.label || 'unknown'}`); + if (p.commits !== undefined) console.log(`Commits: ${p.commits}`); + if (p.changed_files !== undefined) console.log(`Changed: ${p.changed_files} files`); + console.log(`Created: ${formatDate(p.created_at)}`); + console.log(`Updated: ${formatDate(p.updated_at)}`); + if (p.merged_at) console.log(`Merged: ${formatDate(p.merged_at)}`); + console.log(`URL: ${p.html_url}`); + if (p.body) { + console.log(`\n${p.body}`); + } + } catch (err) { + handleError(err); + } + }); + + pr + .command('merge <number>') + .description('Merge a pull request') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .option('--method <method>', 'Merge method: merge|squash|rebase', 'merge') + .option('--message <msg>', 'Merge commit message') + .option('--json', 'Output raw JSON') + .action(async (number: string, opts: { repo?: string; method?: string; message?: string; json?: boolean }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + const methodMap: Record<string, string> = { + merge: 'merge', + squash: 'squash', + rebase: 'rebase', + }; + + const mergeMethod = methodMap[opts.method || 'merge'] || 'merge'; + + try { + await apiRequest(`/repos/${owner}/${repo}/pulls/${number}/merge`, { + method: 'PUT', + token, + body: { + merge_method: mergeMethod, + commit_message: opts.message || '', + }, + }); + + if (opts.json) { + console.log(JSON.stringify({ merged: true, number, method: mergeMethod })); + return; + } + + console.log(`✓ Merged PR #${number} (${mergeMethod})`); + } catch (err) { + handleError(err); + } + }); + + pr + .command('close <number>') + .description('Close a pull request') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .option('--json', 'Output raw JSON') + .action(async (number: string, opts: { repo?: string; json?: boolean }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const updated = await apiRequest<GiteePR>(`/repos/${owner}/${repo}/pulls/${number}`, { + method: 'PATCH', + token, + body: { state: 'closed' }, + }); + + if (opts.json) { + console.log(JSON.stringify(updated, null, 2)); + return; + } + + console.log(`✓ Closed PR #${number}`); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/release.ts b/src/commands/release.ts new file mode 100644 index 0000000..ea9aecb --- /dev/null +++ b/src/commands/release.ts @@ -0,0 +1,121 @@ +import { Command } from 'commander'; +import { getToken } from '../config.js'; +import { apiRequest, resolveRepo, formatDate, handleError } from '../api.js'; + +interface GiteeRelease { + id: number; + tag_name: string; + name: string; + body?: string; + draft: boolean; + prerelease: boolean; + author?: { login: string }; + created_at: string; + assets?: Array<{ id: number; name: string; size: number; download_count: number }>; +} + +export function registerReleaseCommands(program: Command): void { + const release = program + .command('release') + .description('Manage releases'); + + release + .command('list') + .description('List releases') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .option('--page <n>', 'Page number', '1') + .option('--per-page <n>', 'Results per page', '20') + .option('--json', 'Output raw JSON') + .action(async (opts: { repo?: string; page?: string; perPage?: string; json?: boolean }) => { + const token = getToken(); + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const releases = await apiRequest<GiteeRelease[]>(`/repos/${owner}/${repo}/releases`, { + token, + params: { + page: opts.page || 1, + per_page: opts.perPage || 20, + }, + }); + + if (opts.json) { + console.log(JSON.stringify(releases, null, 2)); + return; + } + + if (!releases.length) { + console.log('No releases found.'); + return; + } + + console.log(`Releases in ${repoName}:\n`); + for (const r of releases) { + const flags: string[] = []; + if (r.draft) flags.push('draft'); + if (r.prerelease) flags.push('pre-release'); + const flagStr = flags.length ? ` [${flags.join(', ')}]` : ''; + console.log(` ${r.tag_name} — ${r.name}${flagStr}`); + console.log(` by ${r.author?.login || 'unknown'} · ${formatDate(r.created_at)}`); + if (r.assets?.length) { + console.log(` Assets: ${r.assets.map(a => a.name).join(', ')}`); + } + } + } catch (err) { + handleError(err); + } + }); + + release + .command('create') + .description('Create a release') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .requiredOption('--tag <tag>', 'Tag name (e.g. v1.0.0)') + .requiredOption('--name <name>', 'Release name/title') + .option('--body <desc>', 'Release description') + .option('--draft', 'Create as draft') + .option('--prerelease', 'Mark as pre-release') + .option('--json', 'Output raw JSON') + .action(async (opts: { + repo?: string; + tag: string; + name: string; + body?: string; + draft?: boolean; + prerelease?: boolean; + json?: boolean; + }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + const repoName = resolveRepo(opts.repo); + const [owner, repo] = repoName.split('/'); + + try { + const created = await apiRequest<GiteeRelease>(`/repos/${owner}/${repo}/releases`, { + method: 'POST', + token, + body: { + tag_name: opts.tag, + name: opts.name, + body: opts.body || '', + draft: opts.draft || false, + prerelease: opts.prerelease || false, + }, + }); + + if (opts.json) { + console.log(JSON.stringify(created, null, 2)); + return; + } + + console.log(`✓ Created release: ${created.name} (${created.tag_name})`); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/repo.ts b/src/commands/repo.ts new file mode 100644 index 0000000..a2c8128 --- /dev/null +++ b/src/commands/repo.ts @@ -0,0 +1,224 @@ +import { Command } from 'commander'; +import { execSync } from 'child_process'; +import * as readline from 'readline'; +import { getToken } from '../config.js'; +import { apiRequest, resolveRepo, formatDate, handleError } from '../api.js'; + +interface GiteeRepo { + id: number; + full_name: string; + name: string; + description?: string; + private: boolean; + fork: boolean; + html_url: string; + ssh_url?: string; + clone_url?: string; + homepage?: string; + language?: string; + forks_count?: number; + stargazers_count?: number; + watchers_count?: number; + open_issues_count?: number; + default_branch?: string; + created_at: string; + updated_at: string; + pushed_at?: string; + owner?: { login: string }; +} + +function promptConfirm(question: string): Promise<boolean> { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes'); + }); + }); +} + +export function registerRepoCommands(program: Command): void { + const repo = program + .command('repo') + .description('Manage repositories'); + + repo + .command('list') + .description('List repositories') + .option('--owner <user>', 'List repos for a specific user (default: authenticated user)') + .option('--type <type>', 'Type: all|owner|public|private|member', 'all') + .option('--page <n>', 'Page number', '1') + .option('--per-page <n>', 'Results per page (max 100)', '20') + .option('--json', 'Output raw JSON') + .action(async (opts: { owner?: string; type?: string; page?: string; perPage?: string; json?: boolean }) => { + const token = getToken(); + try { + let repos: GiteeRepo[]; + const params = { + type: opts.type || 'all', + page: opts.page || 1, + per_page: opts.perPage || 20, + }; + + if (opts.owner) { + repos = await apiRequest<GiteeRepo[]>(`/users/${opts.owner}/repos`, { token, params }); + } else { + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + repos = await apiRequest<GiteeRepo[]>('/user/repos', { token, params }); + } + + if (opts.json) { + console.log(JSON.stringify(repos, null, 2)); + return; + } + + if (!repos.length) { + console.log('No repositories found.'); + return; + } + + console.log(`Found ${repos.length} repositories:\n`); + for (const r of repos) { + const visibility = r.private ? '🔒 private' : '🌐 public'; + const stars = r.stargazers_count !== undefined ? `⭐ ${r.stargazers_count}` : ''; + const forks = r.forks_count !== undefined ? `🍴 ${r.forks_count}` : ''; + console.log(` ${r.full_name} [${visibility}] ${stars} ${forks}`); + if (r.description) console.log(` ${r.description}`); + console.log(` ${r.html_url}`); + } + } catch (err) { + handleError(err); + } + }); + + repo + .command('create <name>') + .description('Create a new repository') + .option('--private', 'Make repository private') + .option('--description <desc>', 'Repository description') + .option('--org <org>', 'Create under an organization') + .option('--json', 'Output raw JSON') + .action(async (name: string, opts: { private?: boolean; description?: string; org?: string; json?: boolean }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + try { + const body: Record<string, unknown> = { + name, + private: opts.private || false, + description: opts.description || '', + auto_init: false, + }; + + let endpoint = '/user/repos'; + if (opts.org) { + endpoint = `/orgs/${opts.org}/repos`; + } + + const created = await apiRequest<GiteeRepo>(endpoint, { + method: 'POST', + token, + body, + }); + + if (opts.json) { + console.log(JSON.stringify(created, null, 2)); + return; + } + + console.log(`✓ Created repository: ${created.full_name}`); + console.log(` URL: ${created.html_url}`); + console.log(` Clone: ${created.clone_url || created.ssh_url}`); + } catch (err) { + handleError(err); + } + }); + + repo + .command('view [repo]') + .description('View repository details (owner/repo or auto-detect)') + .option('--repo <owner/repo>', 'Repository (owner/repo)') + .option('--json', 'Output raw JSON') + .action(async (repoArg: string | undefined, opts: { repo?: string; json?: boolean }) => { + const token = getToken(); + const repoName = resolveRepo(repoArg || opts.repo); + const [owner, name] = repoName.split('/'); + + try { + const r = await apiRequest<GiteeRepo>(`/repos/${owner}/${name}`, { token }); + + if (opts.json) { + console.log(JSON.stringify(r, null, 2)); + return; + } + + const visibility = r.private ? '🔒 private' : '🌐 public'; + console.log(`${r.full_name} (${visibility})`); + console.log(`─────────────────────────────`); + if (r.description) console.log(`Description: ${r.description}`); + console.log(`URL: ${r.html_url}`); + console.log(`Clone: ${r.clone_url || r.ssh_url}`); + console.log(`Branch: ${r.default_branch || 'master'}`); + console.log(`Stars: ${r.stargazers_count ?? 0} Forks: ${r.forks_count ?? 0} Watchers: ${r.watchers_count ?? 0}`); + console.log(`Issues: ${r.open_issues_count ?? 0} open`); + if (r.language) console.log(`Language: ${r.language}`); + console.log(`Created: ${formatDate(r.created_at)}`); + console.log(`Updated: ${formatDate(r.updated_at)}`); + } catch (err) { + handleError(err); + } + }); + + repo + .command('clone <repo>') + .description('Clone a Gitee repository (owner/repo)') + .action(async (repoArg: string) => { + const cloneUrl = `https://gitee.com/${repoArg}.git`; + console.log(`Cloning ${cloneUrl}...`); + try { + execSync(`git clone ${cloneUrl}`, { stdio: 'inherit' }); + } catch { + console.error('Error: Clone failed.'); + process.exit(1); + } + }); + + repo + .command('delete <repo>') + .description('Delete a repository (owner/repo) — requires confirmation') + .option('--yes', 'Skip confirmation prompt') + .action(async (repoArg: string, opts: { yes?: boolean }) => { + const token = getToken(); + if (!token) { + console.error('Error: Authentication required. Run `gitee auth login` or set GITEE_TOKEN.'); + process.exit(1); + } + + const [owner, name] = repoArg.split('/'); + if (!owner || !name) { + console.error('Error: Invalid repo format. Use owner/repo.'); + process.exit(1); + } + + if (!opts.yes) { + const confirmed = await promptConfirm(`Are you sure you want to delete ${repoArg}? This is irreversible! (y/N): `); + if (!confirmed) { + console.log('Aborted.'); + return; + } + } + + try { + await apiRequest(`/repos/${owner}/${name}`, { method: 'DELETE', token }); + console.log(`✓ Deleted repository: ${repoArg}`); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..4ee7124 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,40 @@ +import { homedir } from 'os'; +import { join } from 'path'; +import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs'; + +export interface Config { + token?: string; + username?: string; +} + +const CONFIG_DIR = join(homedir(), '.config', 'gitee-cli'); +const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); + +export function getConfig(): Config { + try { + if (existsSync(CONFIG_FILE)) { + return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); + } + } catch { + // ignore + } + return {}; +} + +export function saveConfig(config: Config): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); +} + +export function clearConfig(): void { + if (existsSync(CONFIG_FILE)) { + unlinkSync(CONFIG_FILE); + } +} + +export function getToken(): string | undefined { + // env var has higher priority + return process.env.GITEE_TOKEN || getConfig().token; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fd67e83 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}