Merge PR #303: gitlink-cli 新增 11 个仓库设置相关快捷命令

# Conflicts:
#	shortcuts/repo/repo.go
This commit is contained in:
wbtiger 2026-07-14 22:50:01 +08:00
commit 74c06d76d2
5 changed files with 817 additions and 153 deletions

View File

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

View File

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

View File

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

View File

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

View File

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