Skip to content

Commit fa25cdb

Browse files
mbevc1claude[bot]
andauthored
feat(repos): migrate get repo to the dedicated endpoint, surface id and tags (#1004)
* feat(repos): migrate get repo to the dedicated endpoint, surface id and tags Use `GET /api/v2/repos/{org}/{repo_name}` (added in kosli-dev/server#6156) instead of filtering the list endpoint client-side. The response is a single repo object, so the table output now includes the repo's internal ID (needed for tagging) and its tags, and not-found/ambiguity errors come from the server. The `--repo-id` flag is filtering by internal ID. In case of overlapping names `--provider` is the disambiguator. * fix(repo): improve testing, add flag consistency and loosen unmarshall type handling * Update cmd/kosli/getRepo.go Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * chore(repo): remove duplication, add comments * test(repo): expand test coverage --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
1 parent 4c29e8b commit fa25cdb

7 files changed

Lines changed: 394 additions & 115 deletions

File tree

cmd/kosli/getRepo.go

Lines changed: 106 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"io"
77
"net/http"
88
neturl "net/url"
9+
"sort"
10+
"strings"
911

1012
"github.com/kosli-dev/cli/internal/output"
1113
"github.com/kosli-dev/cli/internal/requests"
@@ -15,19 +17,27 @@ import (
1517
const getRepoShortDesc = `Get a repo for an org.`
1618

1719
const getRepoLongDesc = getRepoShortDesc + `
18-
The name of the repo is specified as an argument (e.g. "my-org/my-repo").
19-
Use --provider or --repo-id to narrow down the result when multiple repos
20-
match the given name.`
20+
The repo is identified either by its name, specified as an argument
21+
(e.g. "my-org/my-repo"), or unambiguously by its internal ID via --repo-id.
22+
The output includes the repo's internal ID, which is the identifier used
23+
to tag the repo (see: kosli tag).
24+
Use --provider to disambiguate when multiple repos share the same name
25+
across VCS providers.`
2126

2227
const getRepoExample = `
2328
# get a repo
2429
kosli get repo my-org/my-repo \
2530
--api-token yourAPIToken \
2631
--org KosliOrgName
2732
28-
# get a repo filtering by provider
33+
# get a repo whose name exists across multiple VCS providers
2934
kosli get repo my-org/my-repo \
3035
--provider github \
36+
--api-token yourAPIToken \
37+
--org KosliOrgName
38+
39+
# get a repo by its internal ID
40+
kosli get repo --repo-id yourRepoID \
3141
--api-token yourAPIToken \
3242
--org KosliOrgName`
3343

@@ -40,16 +50,23 @@ type getRepoOptions struct {
4050
func newGetRepoCmd(out io.Writer) *cobra.Command {
4151
o := new(getRepoOptions)
4252
cmd := &cobra.Command{
43-
Use: "repo REPO-NAME",
53+
Use: "repo [REPO-NAME]",
4454
Short: getRepoShortDesc,
4555
Long: getRepoLongDesc,
4656
Example: getRepoExample,
47-
Args: cobra.ExactArgs(1),
57+
Args: cobra.MaximumNArgs(1),
4858
PreRunE: func(cmd *cobra.Command, args []string) error {
4959
err := RequireGlobalFlags(global, []string{"Org", "ApiToken"})
5060
if err != nil {
5161
return ErrorBeforePrintingUsage(cmd, err.Error())
5262
}
63+
// error unless exactly one of the REPO-NAME arg or --repo-id is set (XOR)
64+
if (len(args) == 1) == (o.repoID != "") {
65+
return ErrorBeforePrintingUsage(cmd, "exactly one of the REPO-NAME argument or --repo-id must be provided")
66+
}
67+
if o.provider != "" && o.repoID != "" {
68+
return ErrorBeforePrintingUsage(cmd, "--provider cannot be combined with --repo-id")
69+
}
5370
return nil
5471
},
5572
RunE: func(cmd *cobra.Command, args []string) error {
@@ -58,26 +75,33 @@ func newGetRepoCmd(out io.Writer) *cobra.Command {
5875
}
5976

6077
cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag)
61-
cmd.Flags().StringVar(&o.provider, "provider", "", "[optional] The VCS provider to filter repos by (e.g. github, gitlab).")
62-
cmd.Flags().StringVar(&o.repoID, "repo-id", "", "[optional] The external repo ID to filter repos by.")
78+
cmd.Flags().StringVar(&o.provider, "provider", "", "[optional] The VCS provider of the repo (e.g. github, gitlab). Required when multiple repos share the same name across providers.")
79+
cmd.Flags().StringVar(&o.repoID, "repo-id", "", "[optional] The repo's internal ID (as shown in the repo output). Identifies the repo unambiguously; cannot be combined with the REPO-NAME argument.")
6380

6481
return cmd
6582
}
6683

6784
func (o *getRepoOptions) run(out io.Writer, args []string) error {
68-
params := neturl.Values{}
69-
params.Set("name", args[0])
70-
if o.provider != "" {
71-
params.Set("provider", o.provider)
72-
}
73-
if o.repoID != "" {
74-
params.Set("repo_id", o.repoID)
85+
// the endpoint's path is the repo name; when fetching by internal id the
86+
// server keys off the ?id= query param and ignores the path, so the id
87+
// intentionally doubles as the path segment instead of a placeholder
88+
pathName := o.repoID
89+
if len(args) == 1 {
90+
pathName = args[0]
7591
}
76-
base, err := neturl.JoinPath(global.Host, "api/v2/repos", global.Org)
92+
reqURL, err := neturl.JoinPath(global.Host, "api/v2/repos", global.Org, pathName)
7793
if err != nil {
7894
return err
7995
}
80-
reqURL := base + "?" + params.Encode()
96+
params := neturl.Values{}
97+
if o.repoID != "" {
98+
params.Set("id", o.repoID)
99+
} else if o.provider != "" {
100+
params.Set("provider", o.provider)
101+
}
102+
if len(params) > 0 {
103+
reqURL += "?" + params.Encode()
104+
}
81105

82106
reqParams := &requests.RequestParams{
83107
Method: http.MethodGet,
@@ -89,46 +113,88 @@ func (o *getRepoOptions) run(out io.Writer, args []string) error {
89113
return err
90114
}
91115

92-
var parsed struct {
93-
Repos []map[string]any `json:"repos"`
94-
}
95-
if err := json.Unmarshal([]byte(response.Body), &parsed); err != nil {
96-
return err
97-
}
98-
if len(parsed.Repos) > 1 {
99-
return fmt.Errorf("found %d repos matching %q. Use --provider or --repo-id to narrow down the search", len(parsed.Repos), args[0])
100-
}
101-
102116
return output.FormattedPrint(response.Body, o.output, out, 0,
103117
map[string]output.FormatOutputFunc{
104118
"table": printRepoAsTable,
105119
"json": output.PrintJson,
106120
})
107121
}
108122

109-
func printRepoAsTable(raw string, out io.Writer, page int) error {
110-
var response struct {
111-
Repos []map[string]any `json:"repos"`
123+
// fetchRepoInnerID resolves a repo name to its internal id via the get repo
124+
// endpoint. An empty provider means no provider disambiguation.
125+
func fetchRepoInnerID(org, repoName, provider string) (string, error) {
126+
reqURL, err := neturl.JoinPath(global.Host, "api/v2/repos", org, repoName)
127+
if err != nil {
128+
return "", err
129+
}
130+
if provider != "" {
131+
params := neturl.Values{}
132+
params.Set("provider", provider)
133+
reqURL += "?" + params.Encode()
112134
}
113135

114-
err := json.Unmarshal([]byte(raw), &response)
136+
reqParams := &requests.RequestParams{
137+
Method: http.MethodGet,
138+
URL: reqURL,
139+
Token: global.ApiToken,
140+
}
141+
response, err := kosliClient.Do(reqParams)
115142
if err != nil {
116-
return err
143+
return "", err
144+
}
145+
146+
var repo struct {
147+
ID string `json:"id"`
148+
}
149+
if err := json.Unmarshal([]byte(response.Body), &repo); err != nil {
150+
return "", err
151+
}
152+
if repo.ID == "" {
153+
return "", fmt.Errorf("could not resolve repo %q to an id", repoName)
117154
}
155+
return repo.ID, nil
156+
}
157+
158+
func printRepoAsTable(raw string, out io.Writer, page int) error {
159+
var repo map[string]any
118160

119-
repos := response.Repos
120-
if len(repos) == 0 {
121-
logger.Info("Repo was not found.")
122-
return nil
161+
err := json.Unmarshal([]byte(raw), &repo)
162+
if err != nil {
163+
return err
123164
}
124165

125-
repo := repos[0]
166+
tagsOutput := formatRepoTags(repo["tags"])
167+
if tagsOutput == "" {
168+
tagsOutput = "None"
169+
}
126170
rows := []string{
127-
fmt.Sprintf("Name:\t%s", repo["name"]),
128-
fmt.Sprintf("URL:\t%s", repo["url"]),
129-
fmt.Sprintf("Provider:\t%s", repo["provider"]),
171+
fmt.Sprintf("Name:\t%v", repo["name"]),
172+
fmt.Sprintf("ID:\t%v", repo["id"]),
173+
fmt.Sprintf("URL:\t%v", repo["url"]),
174+
fmt.Sprintf("Provider:\t%v", repo["provider"]),
175+
fmt.Sprintf("Tags:\t%s", tagsOutput),
130176
}
131177

132178
tabFormattedPrint(out, []string{}, rows)
133179
return nil
134180
}
181+
182+
// formatRepoTags renders a repo's tags map as sorted "key=value" pairs, or ""
183+
// when there are no tags. Following the flow/env table conventions, list
184+
// columns show the empty string while the get repo detail view shows "None".
185+
func formatRepoTags(rawTags any) string {
186+
tags, ok := rawTags.(map[string]any)
187+
if !ok || len(tags) == 0 {
188+
return ""
189+
}
190+
keys := make([]string, 0, len(tags))
191+
for key := range tags {
192+
keys = append(keys, key)
193+
}
194+
sort.Strings(keys)
195+
pairs := make([]string, 0, len(tags))
196+
for _, key := range keys {
197+
pairs = append(pairs, fmt.Sprintf("%s=%v", key, tags[key]))
198+
}
199+
return strings.Join(pairs, ", ")
200+
}

0 commit comments

Comments
 (0)