Skip to content

Commit d357ff0

Browse files
add hidden list repos command (#626)
* add hidden list repos command * comment out flaky tests
1 parent 4f7277b commit d357ff0

4 files changed

Lines changed: 251 additions & 43 deletions

File tree

cmd/kosli/list.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ func newListCmd(out io.Writer) *cobra.Command {
5353
newListTrailsCmd(out),
5454
newListPoliciesCmd(out),
5555
newListAttestationTypesCmd(out),
56+
newListReposCmd(out),
5657
)
5758

5859
return cmd

cmd/kosli/listRepos.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
9+
"github.com/kosli-dev/cli/internal/output"
10+
"github.com/kosli-dev/cli/internal/requests"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
const listReposDesc = `List repos for an org.`
15+
16+
type listReposOptions struct {
17+
listOptions
18+
}
19+
20+
func newListReposCmd(out io.Writer) *cobra.Command {
21+
o := new(listReposOptions)
22+
cmd := &cobra.Command{
23+
Use: "repos",
24+
Hidden: true,
25+
Short: listReposDesc,
26+
Long: listReposDesc,
27+
Args: cobra.NoArgs,
28+
PreRunE: func(cmd *cobra.Command, args []string) error {
29+
err := RequireGlobalFlags(global, []string{"Org", "ApiToken"})
30+
if err != nil {
31+
return ErrorBeforePrintingUsage(cmd, err.Error())
32+
}
33+
return o.validate(cmd)
34+
},
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
return o.run(out)
37+
},
38+
}
39+
40+
addListFlags(cmd, &o.listOptions)
41+
42+
return cmd
43+
}
44+
45+
func (o *listReposOptions) run(out io.Writer) error {
46+
url := fmt.Sprintf("%s/api/v2/repos/%s?page=%d&per_page=%d", global.Host, global.Org, o.pageNumber, o.pageLimit)
47+
48+
reqParams := &requests.RequestParams{
49+
Method: http.MethodGet,
50+
URL: url,
51+
Token: global.ApiToken,
52+
}
53+
response, err := kosliClient.Do(reqParams)
54+
if err != nil {
55+
return err
56+
}
57+
58+
return output.FormattedPrint(response.Body, o.listOptions.output, out, o.pageNumber,
59+
map[string]output.FormatOutputFunc{
60+
"table": printReposListAsTable,
61+
"json": output.PrintJson,
62+
})
63+
}
64+
65+
func printReposListAsTable(raw string, out io.Writer, page int) error {
66+
var repos []map[string]any
67+
var response struct {
68+
Embedded struct {
69+
Repos []map[string]any `json:"repos"`
70+
} `json:"_embedded"`
71+
}
72+
73+
err := json.Unmarshal([]byte(raw), &response)
74+
if err != nil {
75+
return err
76+
}
77+
repos = response.Embedded.Repos
78+
79+
if len(repos) == 0 {
80+
logger.Info("No repos were found.")
81+
return nil
82+
}
83+
84+
header := []string{"NAME", "URL", "LAST_ACTIVITY"}
85+
rows := []string{}
86+
for _, repo := range repos {
87+
row := fmt.Sprintf("%s\t%s\t%s", repo["name"], repo["url"], repo["latest_activity"])
88+
rows = append(rows, row)
89+
}
90+
tabFormattedPrint(out, header, rows)
91+
92+
return nil
93+
}

cmd/kosli/listRepos_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/stretchr/testify/suite"
8+
)
9+
10+
// Define the suite, and absorb the built-in basic suite
11+
// functionality from testify - including a T() method which
12+
// returns the current testing context
13+
type ListReposCommandTestSuite struct {
14+
suite.Suite
15+
defaultKosliArguments string
16+
acmeOrgKosliArguments string
17+
}
18+
19+
func (suite *ListReposCommandTestSuite) SetupTest() {
20+
global = &GlobalOpts{
21+
ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY",
22+
Org: "docs-cmd-test-user",
23+
Host: "http://localhost:8001",
24+
}
25+
suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken)
26+
27+
global.Org = "acme-org"
28+
global.ApiToken = "v3OWZiYWu9G2IMQStYg9BcPQUQ88lJNNnTJTNq8jfvmkR1C5wVpHSs7F00JcB5i6OGeUzrKt3CwRq7ndcN4TTfMeo8ASVJ5NdHpZT7DkfRfiFvm8s7GbsIHh2PtiQJYs2UoN13T8DblV5C4oKb6-yWH73h67OhotPlKfVKazR-c"
29+
suite.acmeOrgKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken)
30+
CreateFlowWithTemplate("list-repos", "testdata/valid_template.yml", suite.Suite.T())
31+
SetEnvVars(map[string]string{
32+
"GITHUB_RUN_NUMBER": "1234",
33+
"GITHUB_SERVER_URL": "https://github.com",
34+
"GITHUB_REPOSITORY": "kosli-dev/cli",
35+
"GITHUB_REPOSITORY_ID": "1234567890",
36+
}, suite.Suite.T())
37+
BeginTrail("trail-name", "list-repos", "", suite.Suite.T())
38+
}
39+
40+
func (suite *ListReposCommandTestSuite) TearDownTest() {
41+
UnSetEnvVars(map[string]string{
42+
"GITHUB_RUN_NUMBER": "",
43+
"GITHUB_SERVER_URL": "",
44+
"GITHUB_REPOSITORY": "",
45+
"GITHUB_REPOSITORY_ID": "",
46+
}, suite.Suite.T())
47+
}
48+
49+
func (suite *ListReposCommandTestSuite) TestListReposCmd() {
50+
tests := []cmdTestCase{
51+
// THIS TEST IS FLAKY IN CI SINCE CI VARIABLES ARE SET THERE AND REPOS MAY EXIST FROM OTHER TESTS
52+
// {
53+
// name: "01-listing repos works when there are repos",
54+
// cmd: fmt.Sprintf(`list repos %s`, suite.defaultKosliArguments),
55+
// golden: "No repos were found.\n",
56+
// },
57+
{
58+
name: "02-listing repos works when there are no repos",
59+
cmd: fmt.Sprintf(`list repos %s`, suite.acmeOrgKosliArguments),
60+
goldenRegex: ".*\nkosli-dev/cli https://github.com/kosli-dev/cli Trail Started at.*",
61+
},
62+
{
63+
name: "03-listing repos with --output json works when there are repos",
64+
cmd: fmt.Sprintf(`list repos --output json %s`, suite.acmeOrgKosliArguments),
65+
goldenJson: []jsonCheck{{"_embedded.repos", "non-empty"}},
66+
},
67+
// THIS TEST IS FLAKY IN CI SINCE CI VARIABLES ARE SET THERE AND REPOS MAY EXIST FROM OTHER TESTS
68+
// {
69+
// name: "04-listing repos with --output json works when there are no repos",
70+
// cmd: fmt.Sprintf(`list repos --output json %s`, suite.defaultKosliArguments),
71+
// goldenJson: []jsonCheck{{"_embedded.repos", "[]"}},
72+
// },
73+
{
74+
wantError: true,
75+
name: "05-providing an argument causes an error",
76+
cmd: fmt.Sprintf(`list repos xxx %s`, suite.defaultKosliArguments),
77+
golden: "Error: unknown command \"xxx\" for \"kosli list repos\"\n",
78+
},
79+
{
80+
wantError: true,
81+
name: "06-negative page limit causes an error",
82+
cmd: fmt.Sprintf(`list repos --page-limit -1 %s`, suite.defaultKosliArguments),
83+
golden: "Error: flag '--page-limit' has value '-1' which is illegal\n",
84+
},
85+
{
86+
wantError: true,
87+
name: "07-negative page number causes an error",
88+
cmd: fmt.Sprintf(`list repos --page -1 %s`, suite.defaultKosliArguments),
89+
golden: "Error: flag '--page' has value '-1' which is illegal\n",
90+
},
91+
{
92+
name: "08-can list repos with pagination",
93+
cmd: fmt.Sprintf(`list repos --page-limit 15 --page 2 %s`, suite.defaultKosliArguments),
94+
golden: "",
95+
},
96+
}
97+
98+
runTestCmd(suite.Suite.T(), tests)
99+
}
100+
101+
// In order for 'go test' to run this suite, we need to create
102+
// a normal test function and pass our suite to suite.Run
103+
func TestListReposCommandTestSuite(t *testing.T) {
104+
suite.Run(t, new(ListReposCommandTestSuite))
105+
}

cmd/kosli/testHelpers.go

Lines changed: 52 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020

2121
type jsonCheck struct {
2222
Path string
23-
Want interface{}
23+
Want any
2424
}
2525

2626
// cmdTestCase describes a cmd test case.
@@ -106,38 +106,53 @@ func goldenPath(filename string) string {
106106
return filepath.Join("testdata", filename)
107107
}
108108

109-
func goldenJsonContains(t *testing.T, output string, path string, want interface{}) {
110-
var data interface{}
109+
func goldenJsonContains(t *testing.T, output string, path string, want any) {
110+
var data any
111111
err := json.Unmarshal([]byte(output), &data)
112112
require.NoError(t, err, "invalid JSON in command output")
113113

114-
// Handle empty path - check root value directly
115-
if path == "" {
116-
// Special case: check for empty array
117-
if want == "[]" || want == "empty" {
118-
list, ok := data.([]interface{})
119-
require.True(t, ok, "expected array at root")
120-
require.Equal(t, 0, len(list), "expected empty array")
121-
return
122-
}
123-
// Special case: check for non-empty array
124-
if want == "non-empty" {
125-
list, ok := data.([]interface{})
126-
require.True(t, ok, "expected array at root")
127-
require.Greater(t, len(list), 0, "expected non-empty array")
128-
return
129-
}
130-
// Special case: check for empty object
131-
if want == "{}" {
132-
obj, ok := data.(map[string]interface{})
133-
require.True(t, ok, "expected object at root")
134-
require.Equal(t, 0, len(obj), "expected empty object")
135-
return
136-
}
137-
require.Equal(t, want, data, "unexpected value at root")
114+
if path != "" {
115+
data = parseJsonData(data, path, t)
116+
}
117+
118+
// Special case: check for empty array
119+
if want == "[]" || want == "empty" {
120+
list, ok := data.([]any)
121+
require.True(t, ok, "expected array at root")
122+
require.Equal(t, 0, len(list), "expected empty array")
123+
return
124+
}
125+
// Special case: check for non-empty array
126+
if want == "non-empty" {
127+
list, ok := data.([]any)
128+
require.True(t, ok, "expected array at root")
129+
require.Greater(t, len(list), 0, "expected non-empty array")
130+
return
131+
}
132+
// Special case: check for empty object
133+
if want == "{}" {
134+
obj, ok := data.(map[string]any)
135+
require.True(t, ok, "expected object at root")
136+
require.Equal(t, 0, len(obj), "expected empty object")
138137
return
139138
}
140139

140+
// Special case: check array length
141+
if wantStr, ok := want.(string); ok && strings.HasPrefix(wantStr, "length:") {
142+
lengthStr := strings.TrimPrefix(wantStr, "length:")
143+
expectedLength, err := strconv.Atoi(lengthStr)
144+
require.NoError(t, err, "invalid length specification: %s", wantStr)
145+
146+
list, ok := data.([]any)
147+
require.True(t, ok, "expected array at path %s", path)
148+
require.Equal(t, expectedLength, len(list), "unexpected array length at path %s", path)
149+
return
150+
}
151+
152+
require.Equal(t, want, data, "unexpected value at path %s", path)
153+
}
154+
155+
func parseJsonData(data any, path string, t *testing.T) any {
141156
current := data
142157
segments := strings.Split(path, ".")
143158
for _, seg := range segments {
@@ -147,34 +162,21 @@ func goldenJsonContains(t *testing.T, output string, path string, want interface
147162
idx, err := strconv.Atoi(idxStr)
148163
require.NoError(t, err, "invalid array index in path: %s", seg)
149164

150-
list, ok := current.([]interface{})
165+
list, ok := current.([]any)
151166
require.True(t, ok, "expected array at %s", seg)
152167
require.True(t, idx < len(list), "index %d out of range", idx)
153168
current = list[idx]
154169
} else {
155170
// map lookup
156-
m, ok := current.(map[string]interface{})
171+
m, ok := current.(map[string]any)
157172
require.True(t, ok, "expected object at %s", seg)
158173

159174
val, exists := m[seg]
160175
require.True(t, exists, "missing key %s", seg)
161176
current = val
162177
}
163178
}
164-
165-
// Special case: check array length
166-
if wantStr, ok := want.(string); ok && strings.HasPrefix(wantStr, "length:") {
167-
lengthStr := strings.TrimPrefix(wantStr, "length:")
168-
expectedLength, err := strconv.Atoi(lengthStr)
169-
require.NoError(t, err, "invalid length specification: %s", wantStr)
170-
171-
list, ok := current.([]interface{})
172-
require.True(t, ok, "expected array at path %s", path)
173-
require.Equal(t, expectedLength, len(list), "unexpected array length at path %s", path)
174-
return
175-
}
176-
177-
require.Equal(t, want, current, "unexpected value at path %s", path)
179+
return current
178180
}
179181

180182
func compareTwoFiles(actualFilename, expectedFilename string) error {
@@ -329,6 +331,13 @@ func BeginTrail(trailName, flowName, templatePath string, t *testing.T) {
329331
payload: TrailPayload{
330332
Name: trailName,
331333
Description: "test trail",
334+
GitRepoInfo: &gitview.GitRepoInfo{
335+
URL: "https://github.com/kosli-dev/cli",
336+
Name: "main",
337+
ID: "1234567890",
338+
Description: "test description",
339+
Provider: "github",
340+
},
332341
},
333342
templateFile: templatePath,
334343
flow: flowName,

0 commit comments

Comments
 (0)