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:
commit
47c32eaf6c
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "项目描述"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue