Skip to content

Commit ccca1eb

Browse files
committed
Add remote repos endpoint
1 parent f7fea64 commit ccca1eb

14 files changed

Lines changed: 863 additions & 84 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package gittokens
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net"
7+
"net/http"
8+
"strconv"
9+
"strings"
10+
11+
"github.com/gomantics/semantix/internal/api/web"
12+
"github.com/gomantics/semantix/internal/domains/gittokens"
13+
"github.com/gomantics/semantix/internal/libs/gitrepo"
14+
"go.uber.org/zap"
15+
)
16+
17+
const defaultPerPage = 30
18+
const maxPerPage = 100
19+
20+
type RemoteReposResponse struct {
21+
Repos []gitrepo.RemoteRepo `json:"repos"`
22+
NextCursor string `json:"next_cursor,omitempty"`
23+
PerPage int `json:"per_page"`
24+
}
25+
26+
func ListRemoteRepos(c web.Context) error {
27+
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
28+
if err != nil {
29+
return c.BadRequest("invalid token id")
30+
}
31+
32+
cursor := c.QueryParam("cursor")
33+
search := c.QueryParam("search")
34+
35+
perPage := defaultPerPage
36+
if pp := c.QueryParam("per_page"); pp != "" {
37+
perPage, err = strconv.Atoi(pp)
38+
if err != nil || perPage < 1 {
39+
return c.BadRequest("invalid per_page")
40+
}
41+
if perPage > maxPerPage {
42+
perPage = maxPerPage
43+
}
44+
}
45+
46+
ctx := c.Request().Context()
47+
48+
token, err := gittokens.GetByID(ctx, id)
49+
if err != nil {
50+
if errors.Is(err, gittokens.ErrNotFound) {
51+
return c.NotFound("git token not found")
52+
}
53+
c.L.Error("failed to get git token", zap.Error(err))
54+
return c.InternalError("failed to get git token")
55+
}
56+
57+
page, err := gitrepo.ListRemoteReposPage(ctx, token.Provider, token.Token, cursor, perPage, search)
58+
if err != nil {
59+
c.L.Error("failed to list remote repos", zap.Error(err), zap.String("provider", string(token.Provider)))
60+
61+
if strings.Contains(err.Error(), "invalid cursor") {
62+
return c.BadRequest("invalid cursor")
63+
}
64+
65+
var netErr net.Error
66+
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
67+
(errors.As(err, &netErr) && netErr.Timeout()) {
68+
return c.Error(http.StatusGatewayTimeout, "upstream provider timed out")
69+
}
70+
return c.Error(http.StatusBadGateway, "failed to reach git provider")
71+
}
72+
73+
repos := page.Repos
74+
if repos == nil {
75+
repos = []gitrepo.RemoteRepo{}
76+
}
77+
78+
return c.OK(RemoteReposResponse{
79+
Repos: repos,
80+
NextCursor: page.NextCursor,
81+
PerPage: perPage,
82+
})
83+
}
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
package gittokens_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
approvals "github.com/approvals/go-approval-tests"
13+
"github.com/gomantics/semantix/internal/libs/gitrepo"
14+
"github.com/gomantics/semantix/internal/testutil"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// allRepos is the full set of repos the mock GitHub server knows about.
19+
var allRepos = []map[string]any{
20+
{
21+
"name": "my-repo",
22+
"full_name": "octocat/my-repo",
23+
"html_url": "https://github.com/octocat/my-repo",
24+
"private": false,
25+
"default_branch": "main",
26+
"description": "A test repo",
27+
},
28+
{
29+
"name": "private-repo",
30+
"full_name": "octocat/private-repo",
31+
"html_url": "https://github.com/octocat/private-repo",
32+
"private": true,
33+
"default_branch": "main",
34+
"description": "",
35+
},
36+
{
37+
"name": "third-repo",
38+
"full_name": "octocat/third-repo",
39+
"html_url": "https://github.com/octocat/third-repo",
40+
"private": false,
41+
"default_branch": "main",
42+
"description": "",
43+
},
44+
}
45+
46+
// mockGitHub starts a test server that responds like the GitHub API.
47+
// /user/repos: page 1 returns two repos, page 2 returns one, beyond that empty.
48+
// /search/repositories: filters allRepos by the q= prefix (before " user:@me").
49+
func mockGitHub(t *testing.T) *httptest.Server {
50+
t.Helper()
51+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52+
w.Header().Set("Content-Type", "application/json")
53+
54+
if r.URL.Path == "/search/repositories" {
55+
q := r.URL.Query().Get("q")
56+
term := q
57+
if i := strings.Index(q, " user:"); i >= 0 {
58+
term = q[:i]
59+
}
60+
var matched []map[string]any
61+
for _, repo := range allRepos {
62+
name := repo["name"].(string)
63+
fullName := repo["full_name"].(string)
64+
if containsFold(name, term) || containsFold(fullName, term) {
65+
matched = append(matched, repo)
66+
}
67+
}
68+
if matched == nil {
69+
matched = []map[string]any{}
70+
}
71+
json.NewEncoder(w).Encode(map[string]any{
72+
"total_count": len(matched),
73+
"items": matched,
74+
})
75+
return
76+
}
77+
78+
// /user/repos - paginated list
79+
switch r.URL.Query().Get("page") {
80+
case "", "1":
81+
json.NewEncoder(w).Encode(allRepos[:2])
82+
case "2":
83+
json.NewEncoder(w).Encode(allRepos[2:3])
84+
default:
85+
json.NewEncoder(w).Encode([]any{})
86+
}
87+
}))
88+
t.Cleanup(srv.Close)
89+
return srv
90+
}
91+
92+
func containsFold(s, sub string) bool {
93+
if sub == "" {
94+
return true
95+
}
96+
sl, subl := len(s), len(sub)
97+
for i := 0; i <= sl-subl; i++ {
98+
if strings.EqualFold(s[i:i+subl], sub) {
99+
return true
100+
}
101+
}
102+
return false
103+
}
104+
105+
// withMockHTTPClient temporarily replaces gitrepo.HTTPClient with one
106+
// that redirects requests to the provided server, then restores the original.
107+
func withMockHTTPClient(t *testing.T, srv *httptest.Server) {
108+
t.Helper()
109+
original := gitrepo.HTTPClient
110+
gitrepo.HTTPClient = &http.Client{
111+
Transport: redirectTransport(srv.URL),
112+
}
113+
t.Cleanup(func() { gitrepo.HTTPClient = original })
114+
}
115+
116+
// redirectTransport rewrites the host of every request to the given base URL.
117+
type redirectTransport string
118+
119+
func (base redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
120+
cloned := req.Clone(req.Context())
121+
cloned.URL.Scheme = "http"
122+
cloned.URL.Host = string(base)[len("http://"):]
123+
return http.DefaultTransport.RoundTrip(cloned)
124+
}
125+
126+
func createTokenForRemoteRepos(t *testing.T, s *testutil.State) string {
127+
t.Helper()
128+
uid := testutil.UniqueID()
129+
created, err := s.Post("/v1/gittokens", map[string]any{
130+
"name": "Remote Repos Token " + uid,
131+
"provider": "github",
132+
"token": "ghp_test1234567890abcd",
133+
})
134+
require.NoError(t, err)
135+
return fmt.Sprintf("%.0f", created["id"].(float64))
136+
}
137+
138+
func TestListRemoteRepos_notFound(t *testing.T) {
139+
t.Parallel()
140+
s := testutil.NewAuthState(t)
141+
142+
err := s.GetStatus("/v1/gittokens/999999999/remote-repos")
143+
testutil.RequireStatus(t, err, http.StatusNotFound)
144+
}
145+
146+
func TestListRemoteRepos_requiresAuth(t *testing.T) {
147+
t.Parallel()
148+
s := testutil.NewState(t)
149+
150+
err := s.GetStatus("/v1/gittokens/1/remote-repos")
151+
testutil.RequireStatus(t, err, http.StatusUnauthorized)
152+
}
153+
154+
func TestListRemoteRepos_invalidID(t *testing.T) {
155+
t.Parallel()
156+
s := testutil.NewAuthState(t)
157+
158+
err := s.GetStatus("/v1/gittokens/not-a-number/remote-repos")
159+
testutil.RequireStatus(t, err, http.StatusBadRequest)
160+
}
161+
162+
func TestListRemoteRepos_invalidPerPage(t *testing.T) {
163+
t.Parallel()
164+
s := testutil.NewAuthState(t)
165+
166+
err := s.GetStatus("/v1/gittokens/1/remote-repos?per_page=bad")
167+
testutil.RequireStatus(t, err, http.StatusBadRequest)
168+
}
169+
170+
// TestListRemoteRepos_success must NOT run in parallel (shared gitrepo.HTTPClient global).
171+
func TestListRemoteRepos_success(t *testing.T) {
172+
srv := mockGitHub(t)
173+
withMockHTTPClient(t, srv)
174+
175+
s := testutil.NewAuthState(t)
176+
id := createTokenForRemoteRepos(t, s)
177+
178+
body, err := s.Get("/v1/gittokens/" + id + "/remote-repos")
179+
require.NoError(t, err)
180+
181+
repos := body["repos"].([]any)
182+
require.Len(t, repos, 2)
183+
require.Equal(t, float64(30), body["per_page"])
184+
// mock page 1 has exactly 2 repos which is less than per_page=30, so no next cursor
185+
_, hasNext := body["next_cursor"]
186+
require.False(t, hasNext)
187+
}
188+
189+
// TestListRemoteRepos_pagination must NOT run in parallel (shared gitrepo.HTTPClient global).
190+
func TestListRemoteRepos_pagination(t *testing.T) {
191+
srv := mockGitHub(t)
192+
withMockHTTPClient(t, srv)
193+
194+
s := testutil.NewAuthState(t)
195+
id := createTokenForRemoteRepos(t, s)
196+
197+
// page 1 with per_page=2: gets 2 repos, exactly fills page, so next_cursor is set
198+
body, err := s.Get("/v1/gittokens/" + id + "/remote-repos?per_page=2")
199+
require.NoError(t, err)
200+
require.Equal(t, float64(2), body["per_page"])
201+
repos := body["repos"].([]any)
202+
require.Len(t, repos, 2)
203+
nextCursor, ok := body["next_cursor"].(string)
204+
require.True(t, ok, "expected next_cursor to be present")
205+
require.NotEmpty(t, nextCursor)
206+
207+
// follow the cursor: gets the third repo, which is less than per_page=2, so no next cursor
208+
body, err = s.Get("/v1/gittokens/" + id + "/remote-repos?per_page=2&cursor=" + nextCursor)
209+
require.NoError(t, err)
210+
repos = body["repos"].([]any)
211+
require.Len(t, repos, 1)
212+
_, hasNext := body["next_cursor"]
213+
require.False(t, hasNext)
214+
}
215+
216+
// TestListRemoteRepos_search must NOT run in parallel (shared gitrepo.HTTPClient global).
217+
func TestListRemoteRepos_search(t *testing.T) {
218+
srv := mockGitHub(t)
219+
withMockHTTPClient(t, srv)
220+
221+
s := testutil.NewAuthState(t)
222+
id := createTokenForRemoteRepos(t, s)
223+
224+
// "private" matches only octocat/private-repo
225+
body, err := s.Get("/v1/gittokens/" + id + "/remote-repos?search=private")
226+
require.NoError(t, err)
227+
repos := body["repos"].([]any)
228+
require.Len(t, repos, 1)
229+
require.Equal(t, "private-repo", repos[0].(map[string]any)["name"])
230+
231+
// "my" matches only octocat/my-repo
232+
body, err = s.Get("/v1/gittokens/" + id + "/remote-repos?search=my")
233+
require.NoError(t, err)
234+
repos = body["repos"].([]any)
235+
require.Len(t, repos, 1)
236+
require.Equal(t, "my-repo", repos[0].(map[string]any)["name"])
237+
238+
// "nomatch" returns empty list
239+
body, err = s.Get("/v1/gittokens/" + id + "/remote-repos?search=nomatch")
240+
require.NoError(t, err)
241+
repos = body["repos"].([]any)
242+
require.Empty(t, repos)
243+
}
244+
245+
// TestListRemoteRepos_invalidCursor must NOT run in parallel (shared gitrepo.HTTPClient global).
246+
func TestListRemoteRepos_invalidCursor(t *testing.T) {
247+
srv := mockGitHub(t)
248+
withMockHTTPClient(t, srv)
249+
250+
s := testutil.NewAuthState(t)
251+
id := createTokenForRemoteRepos(t, s)
252+
253+
err := s.GetStatus("/v1/gittokens/" + id + "/remote-repos?cursor=notanumber")
254+
testutil.RequireStatus(t, err, http.StatusBadRequest)
255+
}
256+
257+
// TestListRemoteRepos_upstreamError must NOT run in parallel (shared gitrepo.HTTPClient global).
258+
func TestListRemoteRepos_upstreamError(t *testing.T) {
259+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
260+
w.WriteHeader(http.StatusInternalServerError)
261+
w.Write([]byte(`{"message":"internal error"}`))
262+
}))
263+
t.Cleanup(srv.Close)
264+
withMockHTTPClient(t, srv)
265+
266+
s := testutil.NewAuthState(t)
267+
id := createTokenForRemoteRepos(t, s)
268+
269+
err := s.GetStatus("/v1/gittokens/" + id + "/remote-repos")
270+
testutil.RequireStatus(t, err, http.StatusBadGateway)
271+
}
272+
273+
// TestListRemoteRepos_upstreamTimeout must NOT run in parallel (shared gitrepo.HTTPClient global).
274+
func TestListRemoteRepos_upstreamTimeout(t *testing.T) {
275+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
276+
<-r.Context().Done()
277+
}))
278+
t.Cleanup(srv.Close)
279+
280+
original := gitrepo.HTTPClient
281+
gitrepo.HTTPClient = &http.Client{
282+
Timeout: 50 * time.Millisecond,
283+
Transport: redirectTransport(srv.URL),
284+
}
285+
t.Cleanup(func() { gitrepo.HTTPClient = original })
286+
287+
s := testutil.NewAuthState(t)
288+
id := createTokenForRemoteRepos(t, s)
289+
290+
err := s.GetStatus("/v1/gittokens/" + id + "/remote-repos")
291+
testutil.RequireStatus(t, err, http.StatusGatewayTimeout)
292+
}
293+
294+
// TestListRemoteRepos_approvals must NOT run in parallel (shared testdata file writes).
295+
func TestListRemoteRepos_approvals(t *testing.T) {
296+
srv := mockGitHub(t)
297+
withMockHTTPClient(t, srv)
298+
299+
s := testutil.NewAuthState(t)
300+
id := createTokenForRemoteRepos(t, s)
301+
302+
body, err := s.Get("/v1/gittokens/" + id + "/remote-repos")
303+
require.NoError(t, err)
304+
305+
approvals.VerifyJSONStruct(t, body)
306+
}

internal/api/gittokens/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ func Configure(e *echo.Echo, l *zap.Logger) {
1111
e.POST("/v1/gittokens", web.WrapAuth(Create, l))
1212
e.PUT("/v1/gittokens/:id", web.WrapAuth(Update, l))
1313
e.DELETE("/v1/gittokens/:id", web.WrapAuth(Delete, l))
14+
e.GET("/v1/gittokens/:id/remote-repos", web.WrapAuth(ListRemoteRepos, l))
1415
}

0 commit comments

Comments
 (0)