Skip to content

Commit 3c09b15

Browse files
fix: make remote repository entry reliable (#1936)
1 parent 715a203 commit 3c09b15

33 files changed

Lines changed: 1099 additions & 163 deletions

apps/backend/internal/github/controller_test.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -936,23 +936,46 @@ func TestHttpGetPRInfo_ServiceError(t *testing.T) {
936936
}
937937

938938
func TestHttpGetPRInfo_NoClient(t *testing.T) {
939+
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
940+
if r.Method != http.MethodGet {
941+
t.Errorf("method = %s, want GET", r.Method)
942+
}
943+
if r.URL.Path != "/repos/acme/widget/pulls/99" {
944+
t.Errorf("path = %q, want public PR endpoint", r.URL.Path)
945+
}
946+
if got := r.Header.Get("Accept"); got != githubAccept {
947+
t.Errorf("Accept = %q, want %q", got, githubAccept)
948+
}
949+
if got := r.Header.Get("X-GitHub-Api-Version"); got != githubAPIVersion {
950+
t.Errorf("X-GitHub-Api-Version = %q, want %q", got, githubAPIVersion)
951+
}
952+
w.Header().Set("Content-Type", "application/json")
953+
_, _ = w.Write([]byte(`{"number":99,"title":"Public widget","state":"open","user":{"login":"octo"},"head":{"ref":"feature/public","sha":"abc123"},"base":{"ref":"main"}}`))
954+
}))
955+
t.Cleanup(api.Close)
956+
957+
originalAPIBase := anonymousAPIBase
958+
anonymousAPIBase = api.URL
959+
t.Cleanup(func() { anonymousAPIBase = originalAPIBase })
960+
939961
router, _ := setupControllerTest(nil)
940962

941963
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/prs/acme/widget/99/info", nil)
942964
w := httptest.NewRecorder()
943965
router.ServeHTTP(w, req)
944966

945-
if w.Code != http.StatusServiceUnavailable {
946-
t.Fatalf("expected 503, got %d: %s", w.Code, w.Body.String())
947-
}
948-
var got struct {
949-
Code string `json:"code"`
967+
if w.Code != http.StatusOK {
968+
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
950969
}
970+
var got PR
951971
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
952972
t.Fatalf("decode response: %v", err)
953973
}
954-
if got.Code != "github_not_configured" {
955-
t.Fatalf("expected github_not_configured code, got %q", got.Code)
974+
if got.Number != 99 || got.Title != "Public widget" {
975+
t.Fatalf("PR = %#v, want public PR details", got)
976+
}
977+
if got.RepoOwner != "acme" || got.RepoName != "widget" || got.HeadBranch != "feature/public" || got.BaseBranch != "main" || got.AuthorLogin != "octo" {
978+
t.Fatalf("PR fallback fields = %#v", got)
956979
}
957980
}
958981

apps/backend/internal/github/service_pr.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package github
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"strings"
78
"sync"
@@ -116,19 +117,25 @@ func pickDefaultMergeMethod(m RepoMergeMethods) string {
116117

117118
// GetPR fetches basic PR details from GitHub.
118119
func (s *Service) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) {
119-
if s.client == nil {
120-
return nil, ErrNoClient
120+
if s.client != nil {
121+
pr, err := s.client.GetPR(ctx, owner, repo, number)
122+
if !errors.Is(err, ErrNoClient) {
123+
return pr, err
124+
}
121125
}
122-
return s.client.GetPR(ctx, owner, repo, number)
126+
return getPRAnonymous(ctx, owner, repo, number)
123127
}
124128

125129
// GetIssue fetches basic issue details from GitHub. The create-task dialog is
126130
// currently the only caller and dedupes requests per URL on the frontend.
127131
func (s *Service) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
128-
if s.client == nil {
129-
return nil, ErrNoClient
132+
if s.client != nil {
133+
issue, err := s.client.GetIssue(ctx, owner, repo, number)
134+
if !errors.Is(err, ErrNoClient) {
135+
return issue, err
136+
}
130137
}
131-
return s.client.GetIssue(ctx, owner, repo, number)
138+
return getIssueAnonymous(ctx, owner, repo, number)
132139
}
133140

134141
// GetPRFeedback fetches live PR feedback from GitHub. Cached briefly with

apps/backend/internal/github/service_reviews.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,47 @@ func listRepoBranchesAnonymous(ctx context.Context, owner, repo string) ([]RepoB
539539
return branches, nil
540540
}
541541

542+
func getPRAnonymous(ctx context.Context, owner, repo string, number int) (*PR, error) {
543+
var raw patPR
544+
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d", url.PathEscape(owner), url.PathEscape(repo), number)
545+
if err := getAnonymous(ctx, endpoint, &raw); err != nil {
546+
return nil, fmt.Errorf("get PR #%d: %w", number, err)
547+
}
548+
return convertPatPR(&raw, owner, repo), nil
549+
}
550+
551+
func getIssueAnonymous(ctx context.Context, owner, repo string, number int) (*Issue, error) {
552+
var raw patIssue
553+
endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), number)
554+
if err := getAnonymous(ctx, endpoint, &raw); err != nil {
555+
return nil, fmt.Errorf("get issue #%d: %w", number, err)
556+
}
557+
return convertPatIssue(&raw, owner, repo), nil
558+
}
559+
560+
func getAnonymous(ctx context.Context, endpoint string, result any) error {
561+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, anonymousAPIBase+endpoint, nil)
562+
if err != nil {
563+
return ErrNoClient
564+
}
565+
req.Header.Set("Accept", githubAccept)
566+
req.Header.Set("X-GitHub-Api-Version", githubAPIVersion)
567+
568+
resp, err := anonymousHTTPClient.Do(req)
569+
if err != nil {
570+
return ErrNoClient
571+
}
572+
defer func() { _ = resp.Body.Close() }()
573+
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
574+
body, _ := io.ReadAll(resp.Body)
575+
return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)}
576+
}
577+
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
578+
return fmt.Errorf("decode GitHub response: %w", err)
579+
}
580+
return nil
581+
}
582+
542583
// parseLinkNext extracts the URL for rel="next" from a GitHub Link header.
543584
// Returns "" if no next page is present.
544585
func parseLinkNext(link string) string {

apps/backend/internal/github/service_test.go

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,99 @@ func TestListRepoBranches_NoopClientFallback_NotFound(t *testing.T) {
133133
}
134134
}
135135

136+
func TestGetPRAndIssue_FallBackWithoutClient(t *testing.T) {
137+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138+
w.Header().Set("Content-Type", "application/json")
139+
switch r.URL.Path {
140+
case "/repos/owner/repo/pulls/7":
141+
_, _ = w.Write([]byte(`{"number":7,"title":"Public PR","state":"open","head":{"ref":"feature"},"base":{"ref":"main"},"user":{"login":"alice"}}`))
142+
case "/repos/owner/repo/issues/8":
143+
_, _ = w.Write([]byte(`{"number":8,"title":"Public issue","state":"open","user":{"login":"alice"}}`))
144+
default:
145+
http.NotFound(w, r)
146+
}
147+
}))
148+
defer srv.Close()
149+
150+
orig := anonymousAPIBase
151+
anonymousAPIBase = srv.URL
152+
defer func() { anonymousAPIBase = orig }()
153+
154+
for _, client := range []Client{nil, &NoopClient{}} {
155+
svc := &Service{client: client}
156+
pr, err := svc.GetPR(context.Background(), "owner", "repo", 7)
157+
if err != nil {
158+
t.Fatalf("GetPR() error = %v", err)
159+
}
160+
if pr.Title != "Public PR" {
161+
t.Fatalf("GetPR() title = %q, want public PR", pr.Title)
162+
}
163+
164+
issue, err := svc.GetIssue(context.Background(), "owner", "repo", 8)
165+
if err != nil {
166+
t.Fatalf("GetIssue() error = %v", err)
167+
}
168+
if issue.Title != "Public issue" {
169+
t.Fatalf("GetIssue() title = %q, want public issue", issue.Title)
170+
}
171+
}
172+
}
173+
174+
func TestGetPRAndIssue_AuthenticatedErrorsAreAuthoritative(t *testing.T) {
175+
orig := anonymousAPIBase
176+
anonymousAPIBase = "http://127.0.0.1:1"
177+
defer func() { anonymousAPIBase = orig }()
178+
179+
prErr := &GitHubAPIError{StatusCode: http.StatusForbidden}
180+
issueErr := &GitHubAPIError{StatusCode: http.StatusNotFound}
181+
svc := &Service{client: &stubClient{
182+
getPRFunc: func(context.Context, string, string, int) (*PR, error) { return nil, prErr },
183+
getIssueFunc: func(context.Context, string, string, int) (*Issue, error) {
184+
return nil, issueErr
185+
},
186+
}}
187+
188+
if _, err := svc.GetPR(context.Background(), "owner", "repo", 7); !errors.Is(err, prErr) {
189+
t.Fatalf("GetPR() error = %v, want authenticated 403", err)
190+
}
191+
if _, err := svc.GetIssue(context.Background(), "owner", "repo", 8); !errors.Is(err, issueErr) {
192+
t.Fatalf("GetIssue() error = %v, want authenticated 404", err)
193+
}
194+
}
195+
196+
func TestGetPRAndIssue_AnonymousStatusIsPreserved(t *testing.T) {
197+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
198+
if strings.Contains(r.URL.Path, "/pulls/") {
199+
w.WriteHeader(http.StatusForbidden)
200+
return
201+
}
202+
w.WriteHeader(http.StatusNotFound)
203+
}))
204+
defer srv.Close()
205+
206+
orig := anonymousAPIBase
207+
anonymousAPIBase = srv.URL
208+
defer func() { anonymousAPIBase = orig }()
209+
210+
svc := &Service{}
211+
for _, request := range []struct {
212+
name string
213+
call func() error
214+
status int
215+
}{
216+
{name: "PR", call: func() error { _, err := svc.GetPR(t.Context(), "owner", "repo", 7); return err }, status: http.StatusForbidden},
217+
{name: "issue", call: func() error { _, err := svc.GetIssue(t.Context(), "owner", "repo", 8); return err }, status: http.StatusNotFound},
218+
} {
219+
t.Run(request.name, func(t *testing.T) {
220+
err := request.call()
221+
var apiErr *GitHubAPIError
222+
if !errors.As(err, &apiErr) || apiErr.StatusCode != request.status {
223+
t.Fatalf("error = %v, want GitHub API status %d", err, request.status)
224+
}
225+
})
226+
}
227+
}
228+
136229
func TestSortBranchesMainFirst(t *testing.T) {
137230
tests := []struct {
138231
input []string
@@ -173,17 +266,6 @@ func TestSortBranchesMainFirst(t *testing.T) {
173266
}
174267
}
175268

176-
func TestGetPR_NilClient(t *testing.T) {
177-
svc := &Service{client: nil}
178-
_, err := svc.GetPR(context.Background(), "owner", "repo", 1)
179-
if err == nil {
180-
t.Fatal("expected error when client is nil")
181-
}
182-
if !errors.Is(err, ErrNoClient) {
183-
t.Errorf("err = %v, want ErrNoClient", err)
184-
}
185-
}
186-
187269
func TestGetPRFeedback_NilClient(t *testing.T) {
188270
svc := &Service{client: nil}
189271
_, err := svc.GetPRFeedback(context.Background(), "owner", "repo", 1)

apps/backend/internal/gitlab/controller_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package gitlab
22

33
import (
4+
"io"
45
"net/http"
56
"net/http/httptest"
67
"net/url"
@@ -11,6 +12,12 @@ import (
1112
"github.com/gin-gonic/gin"
1213
)
1314

15+
type roundTripFunc func(*http.Request) (*http.Response, error)
16+
17+
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
18+
return f(req)
19+
}
20+
1421
// newControllerFixture wires a real PATClient + Controller against an
1522
// httptest.NewServer GitLab stub. The returned *requestLog captures every
1623
// path + query the stub observed so tests can assert exactly which params
@@ -100,6 +107,95 @@ func hit(router *gin.Engine, target string) *httptest.ResponseRecorder {
100107
return w
101108
}
102109

110+
func TestHttpListProjectBranches_UnconfiguredPublicGitLab(t *testing.T) {
111+
gin.SetMode(gin.TestMode)
112+
store := newTestStore(t)
113+
seedWorkspace(t, store, "workspace-test")
114+
svc := NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t))
115+
svc.SetStore(store)
116+
router := gin.New()
117+
NewController(svc, newTestLogger(t)).RegisterHTTPRoutes(router)
118+
119+
originalTransport := http.DefaultTransport
120+
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
121+
if req.URL.Host != "gitlab.com" {
122+
t.Fatalf("anonymous request host = %q, want gitlab.com", req.URL.Host)
123+
}
124+
if got := req.Header.Get("PRIVATE-TOKEN"); got != "" {
125+
t.Fatalf("PRIVATE-TOKEN = %q, want absent for anonymous read", got)
126+
}
127+
return &http.Response{
128+
StatusCode: http.StatusOK,
129+
Header: make(http.Header),
130+
Body: io.NopCloser(strings.NewReader(`[{"name":"main"}]`)),
131+
Request: req,
132+
}, nil
133+
})
134+
t.Cleanup(func() { http.DefaultTransport = originalTransport })
135+
136+
resp := hit(router, "/api/v1/gitlab/projects/branches?project=group%2Fproject&expected_host=https%3A%2F%2Fgitlab.com")
137+
if resp.Code != http.StatusOK {
138+
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
139+
}
140+
if !strings.Contains(resp.Body.String(), `"name":"main"`) {
141+
t.Fatalf("response = %s, want main branch", resp.Body.String())
142+
}
143+
}
144+
145+
func TestHttpListProjectBranches_ConfiguredUpstreamNotFound(t *testing.T) {
146+
gin.SetMode(gin.TestMode)
147+
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
148+
w.WriteHeader(http.StatusNotFound)
149+
}))
150+
t.Cleanup(stop)
151+
152+
store := newTestStore(t)
153+
seedWorkspace(t, store, "workspace-test")
154+
if err := store.UpsertConfigForWorkspace(t.Context(), "workspace-test", &GitLabConfig{
155+
Host: host, AuthMethod: AuthMethodPAT,
156+
}); err != nil {
157+
t.Fatalf("seed GitLab config: %v", err)
158+
}
159+
svc := NewService(host, NewNoopClient(host), AuthMethodNone, nil, newTestLogger(t))
160+
svc.SetStore(store)
161+
svc.SetWorkspaceSecretStore(&configTestSecrets{values: map[string]string{
162+
SecretKeyForWorkspace("workspace-test"): "token",
163+
}})
164+
router := gin.New()
165+
NewController(svc, newTestLogger(t)).RegisterHTTPRoutes(router)
166+
167+
resp := hit(router, "/api/v1/gitlab/projects/branches?project=group%2Fmissing&expected_host="+url.QueryEscape(host))
168+
if resp.Code != http.StatusNotFound {
169+
t.Fatalf("status = %d, want 404; body=%s", resp.Code, resp.Body.String())
170+
}
171+
}
172+
173+
func TestHttpListProjectBranches_UnconfiguredPublicNotFound(t *testing.T) {
174+
gin.SetMode(gin.TestMode)
175+
store := newTestStore(t)
176+
seedWorkspace(t, store, "workspace-test")
177+
svc := NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t))
178+
svc.SetStore(store)
179+
router := gin.New()
180+
NewController(svc, newTestLogger(t)).RegisterHTTPRoutes(router)
181+
182+
originalTransport := http.DefaultTransport
183+
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
184+
return &http.Response{
185+
StatusCode: http.StatusNotFound,
186+
Header: make(http.Header),
187+
Body: io.NopCloser(strings.NewReader(`{"message":"not found"}`)),
188+
Request: req,
189+
}, nil
190+
})
191+
t.Cleanup(func() { http.DefaultTransport = originalTransport })
192+
193+
resp := hit(router, "/api/v1/gitlab/projects/branches?project=group%2Fmissing&expected_host=https%3A%2F%2Fgitlab.com")
194+
if resp.Code != http.StatusNotFound {
195+
t.Fatalf("status = %d, want 404; body=%s", resp.Code, resp.Body.String())
196+
}
197+
}
198+
103199
// Regression for the /gitlab page tabs: each tab value must reach GitLab
104200
// as a real scoping param. Before the translator was added the bare token
105201
// became an empty-value key (e.g. `assigned_to_me=`) and the page served

0 commit comments

Comments
 (0)