Merge PR #303: gitlink-cli 新增 11 个仓库设置相关快捷命令
# Conflicts: # shortcuts/repo/repo.go
This commit is contained in:
commit
74c06d76d2
42
README.md
42
README.md
|
|
@ -140,8 +140,46 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
|||
gitlink-cli repo +create -n my-project -d "Project description"
|
||||
|
||||
# Fork a repository
|
||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
|
||||
# View repository details
|
||||
gitlink-cli repo +detail --owner Gitlink --repo forgeplus
|
||||
|
||||
# View repository simple info
|
||||
gitlink-cli repo +simple --owner Gitlink --repo forgeplus
|
||||
|
||||
# View repository settings
|
||||
gitlink-cli repo +settings --owner Gitlink --repo forgeplus
|
||||
|
||||
# List navigation units
|
||||
gitlink-cli repo +units --owner Gitlink --repo forgeplus
|
||||
|
||||
# Update navigation units
|
||||
gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --unit-types code,issues,pulls --dry-run
|
||||
gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --unit-types code,issues,pulls
|
||||
|
||||
# List project topics
|
||||
gitlink-cli repo +topics --owner Gitlink --repo forgeplus
|
||||
|
||||
# Add a project topic
|
||||
gitlink-cli repo +topic-add --owner Gitlink --repo forgeplus --name "machine-learning" --dry-run
|
||||
gitlink-cli repo +topic-add --owner Gitlink --repo forgeplus --name "machine-learning"
|
||||
|
||||
# Delete a project topic
|
||||
gitlink-cli repo +topic-delete --owner Gitlink --repo forgeplus --id 123 --dry-run
|
||||
gitlink-cli repo +topic-delete --owner Gitlink --repo forgeplus --id 123
|
||||
|
||||
# List organizations available for transfer
|
||||
gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus
|
||||
|
||||
# Apply for repository transfer
|
||||
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --org-id 456 --dry-run
|
||||
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --org-id 456
|
||||
|
||||
# Cancel transfer application
|
||||
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run
|
||||
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Issue Management
|
||||
|
||||
|
|
|
|||
|
|
@ -152,8 +152,46 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
|||
gitlink-cli repo +create -n my-project -d "项目描述"
|
||||
|
||||
# Fork 仓库
|
||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
|
||||
# 查看仓库详情
|
||||
gitlink-cli repo +detail --owner Gitlink --repo forgeplus
|
||||
|
||||
# 查看仓库简要信息
|
||||
gitlink-cli repo +simple --owner Gitlink --repo forgeplus
|
||||
|
||||
# 查看仓库设置
|
||||
gitlink-cli repo +settings --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出导航单元
|
||||
gitlink-cli repo +units --owner Gitlink --repo forgeplus
|
||||
|
||||
# 更新导航单元
|
||||
gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --unit-types code,issues,pulls --dry-run
|
||||
gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --unit-types code,issues,pulls
|
||||
|
||||
# 列出项目标签
|
||||
gitlink-cli repo +topics --owner Gitlink --repo forgeplus
|
||||
|
||||
# 添加项目标签
|
||||
gitlink-cli repo +topic-add --owner Gitlink --repo forgeplus --name "machine-learning" --dry-run
|
||||
gitlink-cli repo +topic-add --owner Gitlink --repo forgeplus --name "machine-learning"
|
||||
|
||||
# 删除项目标签
|
||||
gitlink-cli repo +topic-delete --owner Gitlink --repo forgeplus --id 123 --dry-run
|
||||
gitlink-cli repo +topic-delete --owner Gitlink --repo forgeplus --id 123
|
||||
|
||||
# 列出可转移的组织
|
||||
gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus
|
||||
|
||||
# 申请转移仓库
|
||||
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --org-id 456 --dry-run
|
||||
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --org-id 456
|
||||
|
||||
# 取消转移申请
|
||||
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run
|
||||
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Issue 管理
|
||||
|
||||
|
|
|
|||
|
|
@ -6,17 +6,19 @@ This change expands repository shortcut coverage for repository metadata, settin
|
|||
|
||||
## Commands
|
||||
|
||||
- `repo +detail`
|
||||
- `repo +simple`
|
||||
- `repo +settings`
|
||||
- `repo +units`
|
||||
- `repo +units-update`
|
||||
- `repo +topics`
|
||||
- `repo +topic-add`
|
||||
- `repo +topic-delete`
|
||||
- `repo +transfer-orgs`
|
||||
- `repo +transfer`
|
||||
- `repo +transfer-cancel`
|
||||
| Shortcut | 说明 | 需要认证 |
|
||||
|----------|------|----------|
|
||||
| `repo +detail` | 获取完整项目详情 | 否(公开项目) |
|
||||
| `repo +simple` | 获取简版项目详情 | 否(公开项目) |
|
||||
| `repo +settings` | 获取项目设置 | 是 |
|
||||
| `repo +units` | 获取导航单元配置 | 是 |
|
||||
| `repo +units-update` | 更新导航单元配置 | 是 |
|
||||
| `repo +topics` | 搜索项目标签 | 否(公开项目) |
|
||||
| `repo +topic-add` | 添加项目标签 | 是 |
|
||||
| `repo +topic-delete` | 删除项目标签 | 是 |
|
||||
| `repo +transfer-orgs` | 获取可转移组织列表 | 是 |
|
||||
| `repo +transfer` | 发起仓库转移 | 是 |
|
||||
| `repo +transfer-cancel` | 取消仓库转移 | 是 |
|
||||
|
||||
## API Mapping
|
||||
|
||||
|
|
@ -34,7 +36,78 @@ This change expands repository shortcut coverage for repository metadata, settin
|
|||
| `repo +transfer` | POST | `/api/{owner}/{repo}/applied_transfer_projects.json` |
|
||||
| `repo +transfer-cancel` | POST | `/api/{owner}/{repo}/applied_transfer_projects/cancel.json` |
|
||||
|
||||
## 参数说明
|
||||
|
||||
### repo +detail / +simple / +settings / +units
|
||||
- `--owner`: 仓库所有者(可从 git remote 自动推断)
|
||||
- `--repo`: 仓库名称(可从 git remote 自动推断)
|
||||
|
||||
### repo +units-update
|
||||
- `--owner`: 仓库所有者
|
||||
- `--repo`: 仓库名称
|
||||
- `--unit-types`: 导航单元类型列表,逗号分隔(如 `code,issues,pulls`)
|
||||
- 有效值:`code`, `issues`, `pulls`, `wiki`, `devops`, `versions`, `services`
|
||||
|
||||
### repo +topics
|
||||
- `--keyword`: 搜索关键词(可选)
|
||||
|
||||
### repo +topic-add
|
||||
- `--name`: 标签名称
|
||||
- `--project-id`: 项目 ID(必需)
|
||||
|
||||
### repo +topic-delete
|
||||
- `--id`: 标签 ID
|
||||
- `--project-id`: 项目 ID(必需)
|
||||
|
||||
### repo +transfer-orgs
|
||||
- `--owner`: 仓库所有者
|
||||
- `--repo`: 仓库名称
|
||||
|
||||
### repo +transfer
|
||||
- `--owner`: 仓库所有者
|
||||
- `--repo`: 仓库名称
|
||||
- `--org-name`: 目标组织名称
|
||||
|
||||
### repo +transfer-cancel
|
||||
- `--owner`: 仓库所有者
|
||||
- `--repo`: 仓库名称
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 查看项目详情
|
||||
gitlink-cli repo +detail --owner Gitlink --repo forgeplus
|
||||
gitlink-cli repo +simple --owner Gitlink --repo forgeplus
|
||||
|
||||
# 查看和更新项目设置
|
||||
gitlink-cli repo +settings --owner myuser --repo myproject
|
||||
gitlink-cli repo +units --owner myuser --repo myproject
|
||||
gitlink-cli repo +units-update --owner myuser --repo myproject --unit-types code,issues,pulls,wiki
|
||||
|
||||
# 管理项目标签
|
||||
gitlink-cli repo +topics --keyword "前端"
|
||||
gitlink-cli repo +topic-add --name "Vue" --project-id 123
|
||||
gitlink-cli repo +topic-delete --id 456 --project-id 123
|
||||
|
||||
# 仓库转移
|
||||
gitlink-cli repo +transfer-orgs --owner myuser --repo myproject
|
||||
gitlink-cli repo +transfer --owner myuser --repo myproject --org-name myorg --dry-run
|
||||
gitlink-cli repo +transfer --owner myuser --repo myproject --org-name myorg
|
||||
gitlink-cli repo +transfer-cancel --owner myuser --repo myproject
|
||||
```
|
||||
|
||||
## 实现细节
|
||||
|
||||
- 所有命令遵循现有 `shortcuts/repo/repo.go` 的代码模式
|
||||
- 使用 `ctx.RequireArg()` 获取必需参数
|
||||
- 使用 `ctx.CallAPI()` 调用 API
|
||||
- 写入操作支持 `--dry-run` 参数预览
|
||||
- `repo +units-update` 使用 `parseCommaSeparatedList()` 解析逗号分隔的单元类型列表,自动去重
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests cover request methods, paths, query parameters, JSON payloads, dry-run behavior, CSV de-duplication, and invalid project ID validation.
|
||||
- Write and state-changing commands support `--dry-run`.
|
||||
- 单元测试覆盖所有 11 个新命令
|
||||
- 测试用例包括:请求方法、路径、查询参数、JSON 请求体、dry-run 行为、CSV 去重、无效 project-id 验证
|
||||
- 所有写入和状态变更命令支持 `--dry-run`
|
||||
- 测试文件:`shortcuts/repo/repo_test.go`
|
||||
- 测试命令:`go test ./shortcuts/repo/...`
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
|
@ -82,41 +81,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "units",
|
||||
Description: tr.T("cmd.repo.units.short"),
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", repoUnitsPath(ctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "set-units",
|
||||
Description: tr.T("cmd.repo.set_units.short"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "units", Short: "u", Usage: tr.T("flag.repo.units"), Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
units, err := parseRepoUnits(ctx.Arg("units"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", repoUnitsPath(ctx), map[string]interface{}{"unit_types": units})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tree",
|
||||
Description: tr.T("cmd.repo.tree.short"),
|
||||
|
|
@ -151,12 +115,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
},
|
||||
{
|
||||
Name: "contributors",
|
||||
Description: tr.T("cmd.repo.contributors.short"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "chart", Short: "c", Usage: tr.T("flag.contributors.chart"), Default: ""},
|
||||
{Name: "limit", Short: "l", Usage: tr.T("flag.contributors.limit"), Default: "10"},
|
||||
},
|
||||
Run: runContributors,
|
||||
Description: "List repository contributors",
|
||||
Run: runContributors,
|
||||
},
|
||||
{
|
||||
Name: "contributor-stats",
|
||||
|
|
@ -293,6 +253,274 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// --- Repository Settings Shortcuts ---
|
||||
{
|
||||
Name: "detail",
|
||||
Description: "Show repository full details",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/detail", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "simple",
|
||||
Description: "Show repository simple details",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/simple", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "settings",
|
||||
Description: "Show repository settings",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/edit", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "units",
|
||||
Description: "Show repository navigation units",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_units", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "units-update",
|
||||
Description: "Update repository navigation units",
|
||||
Flags: []common.Flag{
|
||||
{Name: "units", Usage: "Comma-separated list of unit types (code,issues,pulls,wiki,devops,versions,services)", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
unitsRaw, err := ctx.RequireArg("units")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unitTypes := parseCommaSeparatedList(unitsRaw)
|
||||
if len(unitTypes) == 0 {
|
||||
return fmt.Errorf("--units must contain at least one unit type")
|
||||
}
|
||||
validUnits := map[string]bool{
|
||||
"code": true, "issues": true, "pulls": true, "wiki": true,
|
||||
"devops": true, "versions": true, "services": true,
|
||||
}
|
||||
for _, u := range unitTypes {
|
||||
if !validUnits[u] {
|
||||
return fmt.Errorf("invalid unit type %q: must be one of code, issues, pulls, wiki, devops, versions, services", u)
|
||||
}
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"action": "units-update",
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"unit_types": unitTypes,
|
||||
})
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"unit_types": unitTypes,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/project_units", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "topics",
|
||||
Description: "List project topics",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Filter topics by keyword"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
q := url.Values{}
|
||||
if keyword := ctx.Arg("keyword"); keyword != "" {
|
||||
q.Set("keyword", keyword)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/v1/project_topics", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "topic-add",
|
||||
Description: "Add a topic to the repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Topic name", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := resolveRepoProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"action": "topic-add",
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"project_id": projectID,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
"project_id": projectID,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1/project_topics", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "topic-delete",
|
||||
Description: "Delete a topic from the repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Usage: "Topic ID", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
topicID, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := resolveRepoProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"action": "topic-delete",
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"project_id": projectID,
|
||||
"topic_id": topicID,
|
||||
})
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("project_id", projectID)
|
||||
env, err := ctx.CallAPIWithQuery("DELETE", fmt.Sprintf("/v1/project_topics/%s", topicID), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "transfer-orgs",
|
||||
Description: "List organizations that can receive the repository transfer",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/applied_transfer_projects/organizations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "transfer",
|
||||
Description: "Transfer repository to another owner",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Target owner name (user or organization)", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
ownerName, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"action": "transfer",
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"target_owner": ownerName,
|
||||
})
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"owner_name": ownerName,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "transfer-cancel",
|
||||
Description: "Cancel pending repository transfer",
|
||||
Flags: []common.Flag{
|
||||
{Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Arg("dry-run") == "true" {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"action": "transfer-cancel",
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
})
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects/cancel", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -322,64 +550,7 @@ func runContributors(ctx *common.RuntimeContext) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if chart mode is requested
|
||||
chartType := ctx.Arg("chart")
|
||||
if chartType == "" {
|
||||
// Default: output as JSON/table
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
// Parse the response into ContributorsResponse
|
||||
// env.Data contains the API response
|
||||
dataBytes, err := json.Marshal(env.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal response data: %w", err)
|
||||
}
|
||||
|
||||
var resp ContributorsResponse
|
||||
if err := json.Unmarshal(dataBytes, &resp); err != nil {
|
||||
return fmt.Errorf("failed to parse contributors response: %w", err)
|
||||
}
|
||||
|
||||
// Parse limit
|
||||
limit := 10
|
||||
if l := ctx.Arg("limit"); l != "" {
|
||||
if val, err := strconv.Atoi(l); err == nil && val > 0 {
|
||||
limit = val
|
||||
}
|
||||
}
|
||||
|
||||
// Render chart based on type
|
||||
config := ChartConfig{
|
||||
Width: 80,
|
||||
MaxItems: limit,
|
||||
}
|
||||
|
||||
// Apply limit to list for pie and table charts
|
||||
limitedList := resp.List
|
||||
if limit > 0 && len(limitedList) > limit {
|
||||
limitedList = limitedList[:limit]
|
||||
}
|
||||
|
||||
var output string
|
||||
switch strings.ToLower(chartType) {
|
||||
case "bar":
|
||||
output = RenderContributorsChart(&resp, config, ctx.Tr)
|
||||
case "pie":
|
||||
output = RenderPieChart(limitedList, config.Width, ctx.Tr)
|
||||
case "table":
|
||||
output = RenderContributorsTable(limitedList, ctx.Tr)
|
||||
case "all":
|
||||
output = RenderContributorsChart(&resp, config, ctx.Tr) + "\n\n" +
|
||||
RenderPieChart(limitedList, config.Width, ctx.Tr) + "\n\n" +
|
||||
RenderContributorsTable(limitedList, ctx.Tr)
|
||||
default:
|
||||
return fmt.Errorf("unsupported chart type: %s (use: bar, pie, table, or all)", chartType)
|
||||
}
|
||||
|
||||
fmt.Println(output)
|
||||
return nil
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runContributorStats(ctx *common.RuntimeContext) error {
|
||||
|
|
@ -598,40 +769,14 @@ func parseRepoPositiveInt(value, name string) (int, error) {
|
|||
return parsed, nil
|
||||
}
|
||||
|
||||
func repoUnitsPath(ctx *common.RuntimeContext) string {
|
||||
return ctx.RepoPath() + "/project_units"
|
||||
}
|
||||
|
||||
func parseRepoUnits(raw string) ([]string, error) {
|
||||
allowed := map[string]bool{
|
||||
"code": true,
|
||||
"issues": true,
|
||||
"pulls": true,
|
||||
"devops": true,
|
||||
"versions": true,
|
||||
"wiki": true,
|
||||
"services": true,
|
||||
"resources": true,
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
units := []string{}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
unit := strings.ToLower(strings.TrimSpace(part))
|
||||
if unit == "" {
|
||||
continue
|
||||
}
|
||||
if !allowed[unit] {
|
||||
return nil, fmt.Errorf("invalid repository unit %q; allowed values: code,issues,pulls,devops,versions,wiki,services,resources", unit)
|
||||
}
|
||||
if seen[unit] {
|
||||
continue
|
||||
}
|
||||
seen[unit] = true
|
||||
units = append(units, unit)
|
||||
}
|
||||
if len(units) == 0 {
|
||||
return nil, fmt.Errorf("at least one repository unit is required")
|
||||
}
|
||||
return units, nil
|
||||
func parseCommaSeparatedList(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -697,3 +697,373 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
|||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Repository Settings Shortcuts Tests ---
|
||||
|
||||
func TestRepoDetail(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/detail.json")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
"description": "test repo",
|
||||
"identifier": "repo",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "detail", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("detail failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoSimple(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/simple.json")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "simple", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("simple failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoSettings(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/edit.json")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
"description": "test repo",
|
||||
"private": false,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "settings", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("settings failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoUnits(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/project_units.json")
|
||||
writeJSON(t, w, []interface{}{
|
||||
map[string]interface{}{"type": "code"},
|
||||
map[string]interface{}{"type": "issues"},
|
||||
map[string]interface{}{"type": "pulls"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "units", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("units failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoUnitsUpdate(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/owner/repo/project_units.json")
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "units-update", map[string]string{
|
||||
"units": "code,issues,pulls",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("units-update failed: %v", err)
|
||||
}
|
||||
|
||||
unitTypes, ok := body["unit_types"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("unit_types not found in body")
|
||||
}
|
||||
if len(unitTypes) != 3 {
|
||||
t.Fatalf("expected 3 unit types, got %d", len(unitTypes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoUnitsUpdateDryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "units-update", map[string]string{
|
||||
"units": "code,issues",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("units-update dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoUnitsUpdateValidation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("validation error should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]string
|
||||
}{
|
||||
{
|
||||
name: "empty units",
|
||||
args: map[string]string{"units": ""},
|
||||
},
|
||||
{
|
||||
name: "invalid unit type",
|
||||
args: map[string]string{"units": "code,invalid"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runShortcut(t, server, "units-update", tc.args)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTopics(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/project_topics.json")
|
||||
assertEqual(t, r.URL.Query().Get("keyword"), "go")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"topics": []interface{}{
|
||||
map[string]interface{}{"id": float64(1), "name": "golang"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "topics", map[string]string{"keyword": "go"})
|
||||
if err != nil {
|
||||
t.Fatalf("topics failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTopicAdd(t *testing.T) {
|
||||
requests := 0
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
switch requests {
|
||||
case 1:
|
||||
assertRequest(t, r, "GET", "/owner/repo.json")
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
case 2:
|
||||
assertRequest(t, r, "POST", "/v1/project_topics.json")
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(1), "name": "test-topic"})
|
||||
default:
|
||||
t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "topic-add", map[string]string{"name": "test-topic"})
|
||||
if err != nil {
|
||||
t.Fatalf("topic-add failed: %v", err)
|
||||
}
|
||||
assertEqual(t, body["name"], "test-topic")
|
||||
assertEqual(t, body["project_id"], "123")
|
||||
}
|
||||
|
||||
func TestRepoTopicAddDryRun(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
switch requests {
|
||||
case 1:
|
||||
assertRequest(t, r, "GET", "/owner/repo.json")
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
default:
|
||||
t.Fatalf("dry-run should not make extra requests, got: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "topic-add", map[string]string{
|
||||
"name": "test-topic",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("topic-add dry-run failed: %v", err)
|
||||
}
|
||||
assertEqual(t, requests, 1)
|
||||
}
|
||||
|
||||
func TestRepoTopicDelete(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
switch requests {
|
||||
case 1:
|
||||
assertRequest(t, r, "GET", "/owner/repo.json")
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
case 2:
|
||||
assertRequest(t, r, "DELETE", "/v1/project_topics/456.json")
|
||||
assertEqual(t, r.URL.Query().Get("project_id"), "123")
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
default:
|
||||
t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "topic-delete", map[string]string{"id": "456"})
|
||||
if err != nil {
|
||||
t.Fatalf("topic-delete failed: %v", err)
|
||||
}
|
||||
assertEqual(t, requests, 2)
|
||||
}
|
||||
|
||||
func TestRepoTopicDeleteDryRun(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
switch requests {
|
||||
case 1:
|
||||
assertRequest(t, r, "GET", "/owner/repo.json")
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
default:
|
||||
t.Fatalf("dry-run should not make extra requests, got: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "topic-delete", map[string]string{
|
||||
"id": "456",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("topic-delete dry-run failed: %v", err)
|
||||
}
|
||||
assertEqual(t, requests, 1)
|
||||
}
|
||||
|
||||
func TestRepoTransferOrgs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/owner/repo/applied_transfer_projects/organizations.json")
|
||||
writeJSON(t, w, []interface{}{
|
||||
map[string]interface{}{"name": "org1"},
|
||||
map[string]interface{}{"name": "org2"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "transfer-orgs", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("transfer-orgs failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTransfer(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects.json")
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "transfer", map[string]string{"owner": "new-owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("transfer failed: %v", err)
|
||||
}
|
||||
assertEqual(t, body["owner_name"], "new-owner")
|
||||
}
|
||||
|
||||
func TestRepoTransferDryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "transfer", map[string]string{
|
||||
"owner": "new-owner",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transfer dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTransferCancel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects/cancel.json")
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "transfer-cancel", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("transfer-cancel failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTransferCancelDryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "transfer-cancel", map[string]string{"dry-run": "true"})
|
||||
if err != nil {
|
||||
t.Fatalf("transfer-cancel dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTopicAddFailsWithoutName(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call should be made")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "topic-add", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTopicDeleteFailsWithoutID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call should be made")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "topic-delete", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTransferFailsWithoutOwner(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call should be made")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "transfer", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing owner")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue