Compare commits

...

1 Commits

Author SHA1 Message Date
wangyue789 d89f6f6d65 feat(code): add read-only code browsing shortcuts 2026-06-16 08:21:20 +08:00
6 changed files with 183 additions and 1 deletions

View File

@ -0,0 +1,11 @@
# Code read shortcuts
Adds `gitlink-cli code-read` for read-only repository code browsing.
Covered OpenAPI:
- `GET /api/{owner}/{repo}/files.json`
- `GET /api/{owner}/{repo}/entries.json`
- `GET /api/{owner}/{repo}/sub_entries.json`
- `GET /api/v1/{owner}/{repo}/git/trees/{sha}.json`
- `GET /api/v1/{owner}/{repo}/git/blobs/{sha}.json`

View File

@ -0,0 +1,99 @@
package coderead
import (
"fmt"
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{Name: "files", Description: "List all repository files", Flags: []common.Flag{{Name: "search", Short: "s", Usage: "Search keyword"}, {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}}, Run: runFiles},
{Name: "entries", Description: "List root code entries", Flags: []common.Flag{{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}}, Run: runEntries},
{Name: "sub-entry", Description: "View a sub directory or file", Flags: []common.Flag{{Name: "path", Short: "p", Usage: "File or directory path", Required: true}, {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}}, Run: runSubEntry},
{Name: "tree", Description: "View git tree", Flags: []common.Flag{{Name: "sha", Short: "s", Usage: "Tree SHA, branch, or tag", Required: true}, {Name: "recursive", Usage: "Show recursively", Bool: true, Default: "false"}, {Name: "page", Short: "p", Usage: "Page number"}, {Name: "limit", Short: "l", Usage: "Page size"}}, Run: runTree},
{Name: "blob", Description: "View git blob content", Flags: []common.Flag{{Name: "sha", Short: "s", Usage: "Blob SHA", Required: true}}, Run: runBlob},
}
}
func runFiles(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
add(q, "search", ctx.Arg("search"))
add(q, "ref", ctx.Arg("ref"))
return get(ctx, fmt.Sprintf("/%s/%s/files", ctx.Owner, ctx.Repo), q)
}
func runEntries(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
add(q, "ref", ctx.Arg("ref"))
return get(ctx, fmt.Sprintf("/%s/%s/entries", ctx.Owner, ctx.Repo), q)
}
func runSubEntry(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path, err := require(ctx, "path")
if err != nil {
return err
}
q := url.Values{"filepath": {path}}
add(q, "ref", ctx.Arg("ref"))
return get(ctx, fmt.Sprintf("/%s/%s/sub_entries", ctx.Owner, ctx.Repo), q)
}
func runTree(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := require(ctx, "sha")
if err != nil {
return err
}
q := url.Values{}
if parseBool(ctx.Arg("recursive")) {
q.Set("recursive", "true")
}
add(q, "page", ctx.Arg("page"))
add(q, "limit", ctx.Arg("limit"))
return get(ctx, fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, url.PathEscape(sha)), q)
}
func runBlob(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := require(ctx, "sha")
if err != nil {
return err
}
return get(ctx, fmt.Sprintf("/v1/%s/%s/git/blobs/%s", ctx.Owner, ctx.Repo, url.PathEscape(sha)), nil)
}
func get(ctx *common.RuntimeContext, path string, q url.Values) error {
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
}
return ctx.Output(env)
}
func require(ctx *common.RuntimeContext, name string) (string, error) {
v, err := ctx.RequireArg(name)
if err != nil {
return "", err
}
v = strings.TrimSpace(v)
if v == "" {
return "", fmt.Errorf("%s cannot be empty", name)
}
return v, nil
}
func add(q url.Values, k, v string) {
if strings.TrimSpace(v) != "" {
q.Set(k, strings.TrimSpace(v))
}
}
func parseBool(v string) bool { return strings.EqualFold(strings.TrimSpace(v), "true") }

View File

@ -0,0 +1,56 @@
package coderead
import (
"encoding/json"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"net/http"
"net/http/httptest"
"testing"
)
func runCR(t *testing.T, s *httptest.Server, name string, args map[string]string) error {
t.Helper()
var sc *common.Shortcut
for _, x := range Shortcuts() {
if x.Name == name {
sc = x
}
}
if sc == nil {
t.Fatalf("missing %s", name)
}
return sc.Run(&common.RuntimeContext{Client: &client.Client{HTTP: s.Client(), BaseURL: s.URL}, Owner: "alice", Repo: "repo", Args: args, Format: "json", Tr: i18n.Default()})
}
func wj(t *testing.T, w http.ResponseWriter, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func TestCodeReadPaths(t *testing.T) {
paths := []string{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery)
wj(t, w, map[string]interface{}{"status": 0})
}))
defer s.Close()
_ = runCR(t, s, "files", map[string]string{"search": "readme", "ref": "main"})
_ = runCR(t, s, "entries", map[string]string{"ref": "main"})
_ = runCR(t, s, "sub-entry", map[string]string{"path": "docs", "ref": "main"})
_ = runCR(t, s, "tree", map[string]string{"sha": "main", "recursive": "true", "page": "2"})
_ = runCR(t, s, "blob", map[string]string{"sha": "abc"})
want := []string{"GET /alice/repo/files.json?ref=main&search=readme", "GET /alice/repo/entries.json?ref=main", "GET /alice/repo/sub_entries.json?filepath=docs&ref=main", "GET /v1/alice/repo/git/trees/main.json?page=2&recursive=true", "GET /v1/alice/repo/git/blobs/abc.json?"}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("paths=%#v want %q", paths, want[i])
}
}
}
func TestSubEntryRequiresPath(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unexpected API") }))
defer s.Close()
if err := runCR(t, s, "sub-entry", nil); err == nil {
t.Fatal("expected missing path")
}
}

View File

@ -6,6 +6,7 @@ import (
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/coderead"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
@ -36,6 +37,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
}
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(tr),
"code-read": coderead.Shortcuts(),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
@ -60,6 +62,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
descriptions := map[string]string{
"repo": tr.T("cmd.repo.short"),
"code-read": "Repository code browsing operations",
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",

View File

@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) {
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore",
"dataset", "health", "ignore", "code-read",
}
groupSet := map[string]bool{}

View File

@ -0,0 +1,13 @@
# GitLink Code Read Skill
Use for read-only repository code browsing.
```bash
gitlink-cli code-read +files --owner <owner> --repo <repo> --search README --ref master --format json
gitlink-cli code-read +entries --owner <owner> --repo <repo> --ref master --format json
gitlink-cli code-read +sub-entry --owner <owner> --repo <repo> --path docs --ref master --format json
gitlink-cli code-read +tree --owner <owner> --repo <repo> --sha master --recursive --format json
gitlink-cli code-read +blob --owner <owner> --repo <repo> --sha <blob-sha> --format json
```
All commands are read-only.