diff --git a/README.md b/README.md index 3e22f40..91e6162 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,10 @@ gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 gitlink-cli repo +like --owner Gitlink --repo forgeplus gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 +# List and update repository navigation units +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki + # Create a repository gitlink-cli repo +create -n my-project -d "Project description" diff --git a/README.zh-CN.md b/README.zh-CN.md index 7091349..15a8f9c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -262,6 +262,10 @@ gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 gitlink-cli repo +like --owner Gitlink --repo forgeplus gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 +# 查看和更新仓库导航模块 +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki + # 创建仓库 gitlink-cli repo +create -n my-project -d "项目描述" diff --git a/doc/changes/repo-units-shortcut.md b/doc/changes/repo-units-shortcut.md new file mode 100644 index 0000000..779d32e --- /dev/null +++ b/doc/changes/repo-units-shortcut.md @@ -0,0 +1,27 @@ +# Repository Units Shortcut + +## Summary + +Adds repository navigation unit shortcuts for the GitLink project settings API. + +## Commands + +```bash +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki +``` + +## Behavior + +- `repo +units` calls `GET /{owner}/{repo}/project_units`. +- `repo +set-units` calls `POST /{owner}/{repo}/project_units` with `unit_types`. +- `--units` accepts a comma-separated list and validates values before making a request. +- Duplicate unit names are removed while preserving order. + +## Validation + +Allowed units are `code`, `issues`, `pulls`, `devops`, `versions`, `wiki`, `services`, and `resources`. + +## Tests + +- Unit tests cover the read endpoint, update request body, duplicate handling, and invalid input rejection. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 24a84b0..f6d59b2 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -99,8 +99,10 @@ "cmd.repo.fork.short": "Fork a repository", "cmd.repo.info.short": "Show repository details", "cmd.repo.list.short": "List repositories for a user or organization", + "cmd.repo.set_units.short": "Set enabled repository navigation units", "cmd.repo.short": "Repository operations", "cmd.repo.tree.short": "List repository files and directories", + "cmd.repo.units.short": "List enabled repository navigation units", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", "cmd.root.short": "GitLink CLI - command-line tool for GitLink", "cmd.search.repos.short": "Search repositories", @@ -254,6 +256,7 @@ "flag.repo.private": "Make repository private (true/false)", "flag.repo.tree.path": "Directory path to list (default: repository root)", "flag.repo.tree.ref": "Branch, tag, or commit ref", + "flag.repo.units": "Comma-separated units: code,issues,pulls,devops,versions,wiki,services,resources", "flag.search.keyword": "Search keyword", "flag.sort_by": "Sort field", "flag.sort_direction": "Sort direction: asc, desc", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index f3ca917..230302b 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -99,8 +99,10 @@ "cmd.repo.fork.short": "Fork 仓库", "cmd.repo.info.short": "显示仓库详情", "cmd.repo.list.short": "列出用户或组织的仓库", + "cmd.repo.set_units.short": "设置启用的仓库导航模块", "cmd.repo.short": "仓库操作", "cmd.repo.tree.short": "列出仓库文件和目录", + "cmd.repo.units.short": "列出启用的仓库导航模块", "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", "cmd.root.short": "GitLink CLI - GitLink 命令行工具", "cmd.search.repos.short": "搜索仓库", @@ -254,6 +256,7 @@ "flag.repo.private": "设为私有仓库(true/false)", "flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)", "flag.repo.tree.ref": "分支、标签或提交引用", + "flag.repo.units": "逗号分隔的模块:code,issues,pulls,devops,versions,wiki,services,resources", "flag.search.keyword": "搜索关键词", "flag.sort_by": "排序字段", "flag.sort_direction": "排序方向:asc、desc", diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index bae3cf2..9878369 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -1,11 +1,8 @@ package repo import ( - "encoding/base64" - "encoding/json" "fmt" "net/url" - "os" "strconv" "strings" @@ -85,13 +82,39 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "file", - Description: "Show repository file content and metadata", - Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: "Repository file path", Required: true}, - {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, + Name: "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) }, - Run: runFile, }, { Name: "tree", @@ -104,7 +127,15 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - q := repoSubEntriesQuery(ctx.Arg("path"), ctx.Arg("ref")) + q := url.Values{} + ref := ctx.Arg("ref") + if ref == "" { + ref = "master" + } + if path := ctx.Arg("path"); path != "" { + q.Set("filepath", path) + } + q.Set("ref", ref) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) if err != nil { return err @@ -112,36 +143,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "files", - Description: "Search repository files by name", - Flags: []common.Flag{ - {Name: "search", Short: "s", Usage: "File name keyword"}, - {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, - }, - Run: runFiles, - }, - { - Name: "commit-files", - Description: "Commit one file operation or a batch JSON file through contents/batch", - Flags: []common.Flag{ - {Name: "branch", Short: "b", Usage: "Target branch", Required: true}, - {Name: "message", Short: "m", Usage: "Commit message", Required: true}, - {Name: "new-branch", Usage: "Create and commit to a new branch"}, - {Name: "action", Short: "a", Usage: "Single-file action: create, update, or delete; defaults to update"}, - {Name: "path", Short: "p", Usage: "Repository file path for single-file mode"}, - {Name: "content", Short: "c", Usage: "Inline file content for single-file mode"}, - {Name: "from", Usage: "Read single-file content from a local file"}, - {Name: "encoding", Usage: "Content encoding for single-file mode: text or base64; defaults to text"}, - {Name: "ops", Usage: "Read batch file operations from a JSON file"}, - {Name: "author-name", Usage: "Commit author name"}, - {Name: "author-email", Usage: "Commit author email"}, - {Name: "committer-name", Usage: "Committer name"}, - {Name: "committer-email", Usage: "Committer email"}, - {Name: "dry-run", Usage: "Preview the request without committing files", Bool: true, Default: "false"}, - }, - Run: runCommitFiles, - }, { Name: "languages", Description: "Show repository language statistics", @@ -297,271 +298,6 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { return i18n.Default() } -type repoBatchCommitRequest struct { - Files []repoFileOperation `json:"files"` - AuthorEmail string `json:"author_email,omitempty"` - AuthorName string `json:"author_name,omitempty"` - CommitterEmail string `json:"committer_email,omitempty"` - CommitterName string `json:"committer_name,omitempty"` - Branch string `json:"branch"` - NewBranch string `json:"new_branch,omitempty"` - Message string `json:"message"` -} - -type repoFileOperation struct { - ActionType string `json:"action_type"` - Content *string `json:"content,omitempty"` - Encoding string `json:"encoding,omitempty"` - FilePath string `json:"file_path"` -} - -func runFile(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - filePath, err := requiredRepoString(ctx, "path") - if err != nil { - return err - } - q := repoSubEntriesQuery(filePath, ctx.Arg("ref")) - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runFiles(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - setRepoQueryIfPresent(q, "search", ctx.Arg("search")) - setRepoQueryIfPresent(q, "ref", ctx.Arg("ref")) - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runCommitFiles(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - req, err := buildRepoBatchCommitRequest(ctx) - if err != nil { - return err - } - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "method": "POST", - "path": "/v1" + ctx.RepoPath() + "/contents/batch", - "request": req, - }) - } - env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", req) - if err != nil { - return err - } - return ctx.Output(env) -} - -func buildRepoBatchCommitRequest(ctx *common.RuntimeContext) (repoBatchCommitRequest, error) { - branch, err := requiredRepoString(ctx, "branch") - if err != nil { - return repoBatchCommitRequest{}, err - } - message, err := requiredRepoString(ctx, "message") - if err != nil { - return repoBatchCommitRequest{}, err - } - - opsFile := strings.TrimSpace(ctx.Arg("ops")) - files, err := repoCommitFileOperations(ctx, opsFile) - if err != nil { - return repoBatchCommitRequest{}, err - } - - req := repoBatchCommitRequest{ - Files: files, - Branch: branch, - Message: message, - NewBranch: strings.TrimSpace(ctx.Arg("new-branch")), - AuthorName: strings.TrimSpace(ctx.Arg("author-name")), - AuthorEmail: strings.TrimSpace(ctx.Arg("author-email")), - CommitterName: strings.TrimSpace(ctx.Arg("committer-name")), - CommitterEmail: strings.TrimSpace(ctx.Arg("committer-email")), - } - if err := validateRepoIdentityPair("author", req.AuthorName, req.AuthorEmail); err != nil { - return repoBatchCommitRequest{}, err - } - if err := validateRepoIdentityPair("committer", req.CommitterName, req.CommitterEmail); err != nil { - return repoBatchCommitRequest{}, err - } - return req, nil -} - -func repoCommitFileOperations(ctx *common.RuntimeContext, opsFile string) ([]repoFileOperation, error) { - if opsFile != "" { - if repoHasSingleFileArgs(ctx) { - return nil, fmt.Errorf("--ops cannot be combined with --path, --content, --from, --action, or --encoding") - } - return readRepoFileOperations(opsFile) - } - op, err := singleRepoFileOperation(ctx) - if err != nil { - return nil, err - } - return []repoFileOperation{op}, nil -} - -func repoHasSingleFileArgs(ctx *common.RuntimeContext) bool { - for _, name := range []string{"path", "content", "from", "action", "encoding"} { - if strings.TrimSpace(ctx.Arg(name)) != "" { - return true - } - } - return false -} - -func readRepoFileOperations(path string) ([]repoFileOperation, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read --ops file: %w", err) - } - var files []repoFileOperation - if err := json.Unmarshal(data, &files); err == nil { - return validateRepoFileOperations(files) - } - var req repoBatchCommitRequest - if err := json.Unmarshal(data, &req); err != nil { - return nil, fmt.Errorf("parse --ops JSON: expected an array of file operations or an object with files: %w", err) - } - return validateRepoFileOperations(req.Files) -} - -func singleRepoFileOperation(ctx *common.RuntimeContext) (repoFileOperation, error) { - filePath, err := requiredRepoString(ctx, "path") - if err != nil { - return repoFileOperation{}, err - } - action := strings.TrimSpace(ctx.Arg("action")) - if action == "" { - action = "update" - } - op := repoFileOperation{ - ActionType: action, - FilePath: filePath, - Encoding: strings.TrimSpace(ctx.Arg("encoding")), - } - - hasContent := ctx.Arg("content") != "" - fromPath := strings.TrimSpace(ctx.Arg("from")) - hasFrom := fromPath != "" - if hasContent && hasFrom { - return repoFileOperation{}, fmt.Errorf("--content and --from cannot be used together") - } - if hasContent { - content := ctx.Arg("content") - op.Content = &content - } - if hasFrom { - content, err := readRepoFileContent(fromPath, op.Encoding) - if err != nil { - return repoFileOperation{}, err - } - op.Content = &content - } - return validateRepoFileOperation(op, 0) -} - -func readRepoFileContent(path, encoding string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read --from file: %w", err) - } - if strings.TrimSpace(encoding) == "base64" { - return base64.StdEncoding.EncodeToString(data), nil - } - return string(data), nil -} - -func validateRepoFileOperations(files []repoFileOperation) ([]repoFileOperation, error) { - if len(files) == 0 { - return nil, fmt.Errorf("at least one file operation is required") - } - for i := range files { - op, err := validateRepoFileOperation(files[i], i) - if err != nil { - return nil, err - } - files[i] = op - } - return files, nil -} - -func validateRepoFileOperation(op repoFileOperation, index int) (repoFileOperation, error) { - prefix := fmt.Sprintf("files[%d]", index) - op.ActionType = strings.TrimSpace(op.ActionType) - op.FilePath = strings.TrimSpace(op.FilePath) - op.Encoding = strings.TrimSpace(op.Encoding) - - switch op.ActionType { - case "create", "update", "delete": - default: - return repoFileOperation{}, fmt.Errorf("%s.action_type must be create, update, or delete", prefix) - } - if op.FilePath == "" { - return repoFileOperation{}, fmt.Errorf("%s.file_path is required", prefix) - } - if op.ActionType == "delete" { - if op.Content != nil || op.Encoding != "" { - return repoFileOperation{}, fmt.Errorf("%s delete operation must not include content or encoding", prefix) - } - return op, nil - } - if op.Content == nil { - return repoFileOperation{}, fmt.Errorf("%s content is required for create and update operations", prefix) - } - if op.Encoding == "" { - op.Encoding = "text" - } - if op.Encoding != "text" && op.Encoding != "base64" { - return repoFileOperation{}, fmt.Errorf("%s.encoding must be text or base64", prefix) - } - return op, nil -} - -func validateRepoIdentityPair(name, personName, email string) error { - if (personName == "") != (email == "") { - return fmt.Errorf("--%s-name and --%s-email must be provided together", name, name) - } - return nil -} - -func requiredRepoString(ctx *common.RuntimeContext, name string) (string, error) { - value := strings.TrimSpace(ctx.Arg(name)) - if value == "" { - return "", fmt.Errorf("missing required flag: --%s", name) - } - return value, nil -} - -func repoSubEntriesQuery(path, ref string) url.Values { - q := url.Values{} - if path = strings.TrimSpace(path); path != "" { - q.Set("filepath", path) - } - ref = strings.TrimSpace(ref) - if ref == "" { - ref = "master" - } - q.Set("ref", ref) - return q -} - func runLanguages(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -799,3 +535,41 @@ 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 +} diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 76e052a..383b5c6 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -1,12 +1,11 @@ package repo import ( - "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" - "os" + "reflect" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -154,39 +153,65 @@ func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { } } -func TestRepoFileUsesSubEntriesFileEndpoint(t *testing.T) { +func TestRepoUnitsUsesProjectUnitsEndpoint(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") - assertEqual(t, r.URL.Query().Get("filepath"), "README.md") - assertEqual(t, r.URL.Query().Get("ref"), "main") + assertRequest(t, r, "GET", "/owner/repo/project_units.json") + writeJSON(t, w, []string{"code", "issues", "pulls"}) + })) + defer server.Close() + + if err := runShortcut(t, server, "units", nil); err != nil { + t.Fatalf("units shortcut failed: %v", err) + } +} + +func TestRepoSetUnitsSendsValidatedUnitTypes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/project_units.json") + var body struct { + UnitTypes []string `json:"unit_types"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + want := []string{"code", "issues", "pulls"} + if !reflect.DeepEqual(body.UnitTypes, want) { + t.Fatalf("unit_types = %#v, want %#v", body.UnitTypes, want) + } writeJSON(t, w, map[string]interface{}{ - "entries": map[string]interface{}{ - "name": "README.md", - "path": "README.md", - "type": "file", - "content": "# docs\n", - }, + "status": 0, + "message": "success", }) })) defer server.Close() - err := runShortcut(t, server, "file", map[string]string{ - "path": "README.md", - "ref": "main", + err := runShortcut(t, server, "set-units", map[string]string{ + "units": " code,issues,pulls,issues ", }) if err != nil { - t.Fatalf("file shortcut failed: %v", err) + t.Fatalf("set-units shortcut failed: %v", err) } } -func TestRepoFileRequiresPath(t *testing.T) { +func TestRepoSetUnitsRejectsInvalidUnitBeforeRequest(t *testing.T) { + called := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("missing path should not call API, got: %s %s", r.Method, r.URL.Path) + called = true })) defer server.Close() - if err := runShortcut(t, server, "file", map[string]string{"ref": "main"}); err == nil { - t.Fatal("expected missing path error") + err := runShortcut(t, server, "set-units", map[string]string{"units": "code,invalid"}) + if err == nil { + t.Fatalf("expected invalid unit error") + } + if called { + t.Fatalf("server was called for invalid unit") + } +} + +func TestParseRepoUnitsRejectsEmptyInput(t *testing.T) { + if _, err := parseRepoUnits(" , "); err == nil { + t.Fatalf("expected empty unit list error") } } @@ -256,198 +281,6 @@ func TestRepoTreeShortcutRegistersHelpFlags(t *testing.T) { } } -func TestRepoFilesBuildsSearchAndRefQuery(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/files.json") - assertEqual(t, r.URL.Query().Get("search"), "README") - assertEqual(t, r.URL.Query().Get("ref"), "release/v1") - writeJSON(t, w, []map[string]interface{}{ - {"name": "README.md", "path": "README.md", "type": "file"}, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "files", map[string]string{ - "search": " README ", - "ref": " release/v1 ", - }) - if err != nil { - t.Fatalf("files shortcut failed: %v", err) - } -} - -func TestRepoCommitFilesSingleInlineUpdatePostsBatch(t *testing.T) { - var body repoBatchCommitRequest - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/v1/owner/repo/contents/batch.json") - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } - writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc123"}}) - })) - defer server.Close() - - err := runShortcut(t, server, "commit-files", map[string]string{ - "branch": "main", - "new-branch": "docs/update-readme", - "message": "update README", - "path": "README.md", - "content": "# hello\n", - "author-name": "Alice", - "author-email": "alice@example.com", - "committer-name": "Bob", - "committer-email": "bob@example.com", - }) - if err != nil { - t.Fatalf("commit-files shortcut failed: %v", err) - } - assertEqual(t, body.Branch, "main") - assertEqual(t, body.NewBranch, "docs/update-readme") - assertEqual(t, body.Message, "update README") - assertEqual(t, body.AuthorName, "Alice") - assertEqual(t, body.AuthorEmail, "alice@example.com") - assertEqual(t, len(body.Files), 1) - assertEqual(t, body.Files[0].ActionType, "update") - assertEqual(t, body.Files[0].FilePath, "README.md") - assertEqual(t, body.Files[0].Encoding, "text") - if body.Files[0].Content == nil || *body.Files[0].Content != "# hello\n" { - t.Fatalf("unexpected content: %#v", body.Files[0].Content) - } -} - -func TestRepoCommitFilesReadsLocalFileAsBase64(t *testing.T) { - tempFile := writeTempFile(t, []byte{0x00, 0x01, 0x02, 0xff}) - - var body repoBatchCommitRequest - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/v1/owner/repo/contents/batch.json") - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } - writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "def456"}}) - })) - defer server.Close() - - err := runShortcut(t, server, "commit-files", map[string]string{ - "branch": "main", - "message": "add binary", - "action": "create", - "path": "assets/logo.bin", - "from": tempFile, - "encoding": "base64", - }) - if err != nil { - t.Fatalf("commit-files shortcut failed: %v", err) - } - assertEqual(t, body.Files[0].ActionType, "create") - assertEqual(t, body.Files[0].Encoding, "base64") - want := base64.StdEncoding.EncodeToString([]byte{0x00, 0x01, 0x02, 0xff}) - if body.Files[0].Content == nil || *body.Files[0].Content != want { - t.Fatalf("content = %#v, want %q", body.Files[0].Content, want) - } -} - -func TestRepoCommitFilesReadsBatchOpsFile(t *testing.T) { - opsFile := writeTempFile(t, []byte(`[ - {"action_type":"create","file_path":"docs/a.md","content":"hello","encoding":"text"}, - {"action_type":"delete","file_path":"docs/old.md"} - ]`)) - - var body repoBatchCommitRequest - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/v1/owner/repo/contents/batch.json") - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } - writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "789"}}) - })) - defer server.Close() - - err := runShortcut(t, server, "commit-files", map[string]string{ - "branch": "main", - "message": "batch docs", - "ops": opsFile, - }) - if err != nil { - t.Fatalf("commit-files shortcut failed: %v", err) - } - assertEqual(t, len(body.Files), 2) - assertEqual(t, body.Files[0].ActionType, "create") - assertEqual(t, body.Files[0].Encoding, "text") - assertEqual(t, body.Files[1].ActionType, "delete") - if body.Files[1].Content != nil || body.Files[1].Encoding != "" { - t.Fatalf("delete operation should omit content and encoding: %+v", body.Files[1]) - } -} - -func TestRepoCommitFilesDryRunDoesNotCallAPI(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, "commit-files", map[string]string{ - "branch": "main", - "message": "preview", - "path": "README.md", - "content": "hello", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("dry-run shortcut failed: %v", err) - } -} - -func TestRepoCommitFilesValidation(t *testing.T) { - opsFile := writeTempFile(t, []byte(`[{"action_type":"create","file_path":"docs/a.md","content":"hello"}]`)) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - cases := []struct { - name string - args map[string]string - }{ - { - name: "missing path", - args: map[string]string{"branch": "main", "message": "msg", "content": "hello"}, - }, - { - name: "missing content for update", - args: map[string]string{"branch": "main", "message": "msg", "path": "README.md"}, - }, - { - name: "content and from together", - args: map[string]string{"branch": "main", "message": "msg", "path": "README.md", "content": "hello", "from": opsFile}, - }, - { - name: "delete with content", - args: map[string]string{"branch": "main", "message": "msg", "action": "delete", "path": "README.md", "content": "hello"}, - }, - { - name: "invalid encoding", - args: map[string]string{"branch": "main", "message": "msg", "path": "README.md", "content": "hello", "encoding": "gzip"}, - }, - { - name: "ops with single-file args", - args: map[string]string{"branch": "main", "message": "msg", "ops": opsFile, "path": "README.md"}, - }, - { - name: "partial author identity", - args: map[string]string{"branch": "main", "message": "msg", "path": "README.md", "content": "hello", "author-name": "Alice"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if err := runShortcut(t, server, "commit-files", tc.args); err == nil { - t.Fatal("expected validation error") - } - }) - } -} - func TestRepoLanguagesUsesLanguagesEndpoint(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertRequest(t, r, "GET", "/owner/repo/languages.json") @@ -851,21 +684,6 @@ func TestRepoCreateUserNoLogin(t *testing.T) { } } -func writeTempFile(t *testing.T, data []byte) string { - t.Helper() - file, err := os.CreateTemp(t.TempDir(), "repo-file-*") - if err != nil { - t.Fatalf("create temp file: %v", err) - } - if _, err := file.Write(data); err != nil { - t.Fatalf("write temp file: %v", err) - } - if err := file.Close(); err != nil { - t.Fatalf("close temp file: %v", err) - } - return file.Name() -} - func assertRequest(t *testing.T, r *http.Request, method, path string) { t.Helper() if r.Method != method || r.URL.Path != path { diff --git a/skills/README.md b/skills/README.md index d5700ef..eaf1799 100644 --- a/skills/README.md +++ b/skills/README.md @@ -103,8 +103,6 @@ skills/ │ └── SKILL.md # Pipeline 操作指南 ├── gitlink-wiki/ # Wiki 页面管理 │ └── SKILL.md # Wiki 操作指南 -├── gitlink-trace/ # 代码溯源分析 -│ └── SKILL.md # 代码溯源分析操作指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 ├── gitlink-health/ # 项目健康度分析 @@ -129,8 +127,8 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | -| **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` | -| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete`, `issue +export` | +| **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like`, `repo +units`, `repo +set-units` | +| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | @@ -146,7 +144,6 @@ skills/ | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | | **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` | | **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | -| **gitlink-trace** | 代码溯源分析 | `trace +init`, `trace +start`, `trace +results`, `trace +report` | | **gitlink-pm** | 项目管理 | 通过 Raw API 访问 | | **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | | **gitlink-health** | 开源项目健康度 | 详情见SKILL.md | diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 7f3dfb6..1ba76b8 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -103,6 +103,17 @@ gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes gitlink-cli repo +delete --owner myuser --repo old-project ``` +## Repository Units + +Use these shortcuts to inspect or update the repository navigation modules shown in GitLink: + +```bash +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki +``` + +Allowed unit values: `code`, `issues`, `pulls`, `devops`, `versions`, `wiki`, `services`, `resources`. + ## Raw API 补充 Shortcuts 未覆盖的仓库操作可用 Raw API: