Merge PR #67: feat(repo): add repository units shortcuts

# Conflicts:
#	shortcuts/repo/repo.go
#	shortcuts/repo/repo_test.go
#	skills/README.md
This commit is contained in:
wbtiger 2026-07-14 22:50:00 +08:00
commit 47c32eaf6c
9 changed files with 178 additions and 537 deletions

View File

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

View File

@ -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 "项目描述"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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