Skip to content

Commit 3cdf95a

Browse files
fix(vcs): discover private repositories of personal accounts (#42)
git-sync listed a GitHub personal account through GET /users/{owner}/repos, which returns public repositories only whatever token is presented, so private repositories were silently missing from every run with no error to point at. When the token belongs to the configured owner, list through GET /user/repos?visibility=all&affiliation=owner instead, which reports them. Mirroring another account still sees its public repositories only, since no credential can see further. A GitLab source in a user namespace failed outright at the discover stage, because only GET /groups/{owner}/projects was tried and it 404s for a personal namespace. A 404 on the first page now falls back to GET /users/{owner}/projects. A mirror whose discovery succeeds but reports nothing now warns and names the likely cause, since an empty run was otherwise indistinguishable from a mirror that is up to date. Co-authored-by: Anmol Nagpal <ianmolnagpal@gmail.com>
1 parent 48e357c commit 3cdf95a

7 files changed

Lines changed: 220 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- `git-sync` discovered only the public repositories of a GitHub personal account. The listing fell back to `GET /users/{owner}/repos` when the owner was not an organisation, and that endpoint returns public repositories only, whatever token is presented, so private repositories were silently missing from every run with no error to point at. When the token belongs to the configured owner, SyncerD now lists through `GET /user/repos?visibility=all&affiliation=owner`, which reports private repositories. Mirroring another account still sees its public repositories only, since no credential can see further
12+
- `git-sync` failed outright at the `discover` stage when a GitLab source was a user namespace rather than a group: only `GET /groups/{owner}/projects` was tried, and it 404s for a personal namespace. A 404 on the first page now falls back to `GET /users/{owner}/projects`, which lists private projects the token can see
13+
14+
### Changed
15+
- A `git-sync` mirror whose source discovery succeeds but reports no repositories at all now logs a warning naming the likely cause (a token that cannot read the source's private repositories), because an empty run was otherwise indistinguishable from a mirror that is up to date
16+
1017
## [0.1.2] - 2026-08-14
1118

1219
### Added

docs/git-sync-runbook.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ query and the one-file-per-command note.
210210

211211
| Provider | Token | Scopes |
212212
|---|---|---|
213-
| GitHub | fine-grained PAT | Metadata read, Contents read, Administration write (only if `create_missing`) |
213+
| GitHub | fine-grained PAT | Metadata read, Contents read, Administration write (only if `create_missing`); a fine grained token must also grant access to each private repository you expect to be mirrored, or discovery reports fewer repositories than the source holds |
214214
| GitLab | group access token | `api` to create projects, plus `write_repository` |
215215

216216
Environment variable names derive from the **provider name** in your config,
@@ -576,6 +576,17 @@ config that looks valid can still fail at the credential or network step:
576576
Bitbucket also has no archived concept, so `skip_archived` has no effect
577577
for a Bitbucket source.
578578

579+
**A personal account source needs a token belonging to that account.**
580+
GitHub's `GET /users/{owner}/repos` returns public repositories only, whatever
581+
token is presented, so SyncerD lists a personal account through
582+
`GET /user/repos` instead, which reports private repositories too. That
583+
endpoint only ever describes the account the token belongs to. Mirroring
584+
someone else's personal account therefore discovers its public repositories
585+
and nothing more, because no credential can see further. Organisation owners
586+
are unaffected: `GET /orgs/{org}/repos` reports private repositories to any
587+
token with access. The same shape applies to GitLab, where a user namespace
588+
falls back to `GET /users/{owner}/projects`.
589+
579590
**Released binaries need git on PATH.** goreleaser ships a bare binary with
580591
no bundled git; install git 2.30 or newer on any host that runs a release
581592
binary. The Docker image installs it for you.

internal/gitsync/sync.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,16 @@ func (e *Engine) Run(ctx context.Context, mirrors []Mirror) (*GitReport, error)
214214
logging.Info(fmt.Sprintf("mirror %s: %d repositories discovered, %d selected", m.Name, len(repos), len(selected)),
215215
"mirror", m.Name, "discovered", len(repos), "selected", len(selected))
216216

217+
// Discovery that succeeds but reports nothing is the shape a
218+
// under scoped token takes: the API answers, and answers with the
219+
// public half of an account, or with nothing at all. Say so, since
220+
// an empty run is otherwise indistinguishable from a mirror that
221+
// is genuinely up to date.
222+
if len(repos) == 0 {
223+
logging.Warn(fmt.Sprintf("mirror %s: source provider reported no repositories at all; if the source holds private repositories, check that the token can read them (a GitHub token needs the repo scope, or repository access on a fine grained token; a GitLab token needs read_api)", m.Name),
224+
"mirror", m.Name)
225+
}
226+
217227
if err := e.checkDestinationCollisions(m, selected); err != nil {
218228
e.addFailure(GitFailure{Mirror: m.Name, Stage: "discover", Error: e.redact(err.Error())})
219229
if e.opts.FailFast {

internal/vcs/github/github.go

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ type Provider struct {
4242

4343
ownerMu sync.Mutex
4444
ownerIsOrg *bool
45+
46+
loginMu sync.Mutex
47+
login *string
4548
}
4649

4750
// New validates cfg and returns a provider.
@@ -154,13 +157,71 @@ func (p *Provider) ListRepos(ctx context.Context) ([]vcs.Repo, error) {
154157
// A 404 partway through is a real failure, not a reason to restart
155158
// against a different endpoint and lose what we already collected.
156159
if isNotFound(err) && len(repos) == 0 {
157-
userURL := fmt.Sprintf("%s/users/%s/repos?per_page=100&type=all", p.apiURL, p.owner)
158-
return p.listFrom(ctx, userURL)
160+
return p.listUserRepos(ctx)
159161
}
160162

161163
return nil, err
162164
}
163165

166+
// listUserRepos lists a personal account's repositories.
167+
//
168+
// GET /users/{username}/repos returns public repositories only, whatever
169+
// the token: GitHub documents it as "Lists public repositories for the
170+
// specified user", and it does not widen for an authenticated caller. A
171+
// mirror of a personal account configured that way silently discovers only
172+
// the public half, with no error to point at.
173+
//
174+
// GET /user/repos is the endpoint that reports private repositories, but it
175+
// only ever describes the account the token belongs to. So when the token
176+
// belongs to the configured owner, that endpoint is used; otherwise the
177+
// public listing is all any credential can see anyway.
178+
func (p *Provider) listUserRepos(ctx context.Context) ([]vcs.Repo, error) {
179+
login, err := p.authenticatedLogin(ctx)
180+
if err != nil {
181+
// The token could not be resolved to an account. Fall back to the
182+
// public listing rather than failing discovery outright, so a
183+
// mirror of a public personal account keeps working.
184+
login = ""
185+
}
186+
187+
if login != "" && strings.EqualFold(login, p.owner) {
188+
selfURL := fmt.Sprintf("%s/user/repos?per_page=100&affiliation=owner&visibility=all", p.apiURL)
189+
return p.listFrom(ctx, selfURL)
190+
}
191+
192+
userURL := fmt.Sprintf("%s/users/%s/repos?per_page=100&type=all", p.apiURL, p.owner)
193+
return p.listFrom(ctx, userURL)
194+
}
195+
196+
// authenticatedLogin returns the account login the token belongs to. The
197+
// answer is cached because it cannot change during a run, and the lock is
198+
// held across the lookup so concurrent workers resolve it once. A token
199+
// that GET /user does not accept, such as a GitHub App installation token,
200+
// yields an error rather than a login.
201+
func (p *Provider) authenticatedLogin(ctx context.Context) (string, error) {
202+
p.loginMu.Lock()
203+
defer p.loginMu.Unlock()
204+
205+
if p.login != nil {
206+
return *p.login, nil
207+
}
208+
209+
body, _, err := p.do(ctx, http.MethodGet, p.apiURL+"/user", nil)
210+
if err != nil {
211+
// Not cached, so a later call can retry.
212+
return "", err
213+
}
214+
215+
var who struct {
216+
Login string `json:"login"`
217+
}
218+
if err := json.Unmarshal(body, &who); err != nil {
219+
return "", fmt.Errorf("github: decode authenticated user: %w", err)
220+
}
221+
p.login = &who.Login
222+
return who.Login, nil
223+
}
224+
164225
// maxPages bounds a paginated listing. GitHub caps at 100 per page, so this
165226
// allows 100000 repositories, far beyond any real org, while stopping a
166227
// server that returns a cyclic next link.

internal/vcs/github/github_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"net/http"
88
"net/http/httptest"
9+
"net/url"
910
"strings"
1011
"sync"
1112
"testing"
@@ -121,6 +122,64 @@ func TestListReposFallsBackToUserEndpoint(t *testing.T) {
121122
}
122123
}
123124

125+
func TestListReposUsesAuthenticatedEndpointForTheTokenOwner(t *testing.T) {
126+
var query url.Values
127+
mux := http.NewServeMux()
128+
mux.HandleFunc("/orgs/acme/repos", func(w http.ResponseWriter, r *http.Request) {
129+
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
130+
})
131+
mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
132+
writeJSON(w, map[string]any{"login": "Acme"})
133+
})
134+
mux.HandleFunc("/user/repos", func(w http.ResponseWriter, r *http.Request) {
135+
query = r.URL.Query()
136+
writeJSON(w, []map[string]any{{"name": "secret", "full_name": "acme/secret", "default_branch": "main"}})
137+
})
138+
mux.HandleFunc("/users/acme/repos", func(w http.ResponseWriter, r *http.Request) {
139+
t.Error("the public user listing hides private repositories and must not be used for the token owner")
140+
})
141+
142+
p, _ := newProvider(t, mux)
143+
repos, err := p.ListRepos(context.Background())
144+
if err != nil {
145+
t.Fatalf("list: %v", err)
146+
}
147+
if len(repos) != 1 || repos[0].Name != "secret" {
148+
t.Fatalf("unexpected repos: %+v", repos)
149+
}
150+
if got := query.Get("visibility"); got != "all" {
151+
t.Errorf("visibility = %q, want all", got)
152+
}
153+
if got := query.Get("affiliation"); got != "owner" {
154+
t.Errorf("affiliation = %q, want owner", got)
155+
}
156+
}
157+
158+
func TestListReposUsesPublicEndpointForAnotherAccount(t *testing.T) {
159+
mux := http.NewServeMux()
160+
mux.HandleFunc("/orgs/acme/repos", func(w http.ResponseWriter, r *http.Request) {
161+
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
162+
})
163+
mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
164+
writeJSON(w, map[string]any{"login": "someone-else"})
165+
})
166+
mux.HandleFunc("/user/repos", func(w http.ResponseWriter, r *http.Request) {
167+
t.Error("the authenticated listing describes the token owner, not the configured owner")
168+
})
169+
mux.HandleFunc("/users/acme/repos", func(w http.ResponseWriter, r *http.Request) {
170+
writeJSON(w, []map[string]any{{"name": "personal", "full_name": "acme/personal", "default_branch": "main"}})
171+
})
172+
173+
p, _ := newProvider(t, mux)
174+
repos, err := p.ListRepos(context.Background())
175+
if err != nil {
176+
t.Fatalf("list: %v", err)
177+
}
178+
if len(repos) != 1 || repos[0].Name != "personal" {
179+
t.Fatalf("unexpected repos: %+v", repos)
180+
}
181+
}
182+
124183
func TestListReposSurfacesServerError(t *testing.T) {
125184
mux := http.NewServeMux()
126185
mux.HandleFunc("/orgs/acme/repos", func(w http.ResponseWriter, r *http.Request) {

internal/vcs/gitlab/gitlab.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,38 @@ const maxPages = 1000
128128
// returns a cyclic or repeated page number cannot loop it forever; whatever
129129
// was collected before a bound trip is still returned alongside the error.
130130
func (p *Provider) ListRepos(ctx context.Context) ([]vcs.Repo, error) {
131+
groupPage := func(page string) string {
132+
return fmt.Sprintf("%s/api/v4/groups/%s/projects?include_subgroups=true&per_page=100&archived=&page=%s",
133+
p.base, url.PathEscape(p.owner), url.QueryEscape(page))
134+
}
135+
136+
repos, err := p.listPages(ctx, groupPage)
137+
if err == nil {
138+
return repos, nil
139+
}
140+
141+
// A 404 on the very first page means the owner is a user namespace
142+
// rather than a group. GET /users/{owner}/projects lists that
143+
// namespace, including its private projects when the token can see
144+
// them, so a personal namespace is mirrorable instead of failing
145+
// discovery outright. A 404 partway through is a real failure, not a
146+
// reason to restart against a different endpoint.
147+
if isNotFound(err) && len(repos) == 0 {
148+
userPage := func(page string) string {
149+
return fmt.Sprintf("%s/api/v4/users/%s/projects?per_page=100&archived=&page=%s",
150+
p.base, url.PathEscape(p.owner), url.QueryEscape(page))
151+
}
152+
return p.listPages(ctx, userPage)
153+
}
154+
155+
return nil, err
156+
}
157+
158+
// listPages walks a paginated project listing built by pageURL, following
159+
// the X-Next-Page header. Whatever was collected before an error is still
160+
// returned alongside it, so the caller can tell a first page failure from a
161+
// failure partway through.
162+
func (p *Provider) listPages(ctx context.Context, pageURL func(page string) string) ([]vcs.Repo, error) {
131163
var out []vcs.Repo
132164
page := "1"
133165
seen := make(map[string]bool)
@@ -141,10 +173,7 @@ func (p *Provider) ListRepos(ctx context.Context) ([]vcs.Repo, error) {
141173
}
142174
seen[page] = true
143175

144-
endpoint := fmt.Sprintf("%s/api/v4/groups/%s/projects?include_subgroups=true&per_page=100&archived=&page=%s",
145-
p.base, url.PathEscape(p.owner), url.QueryEscape(page))
146-
147-
body, header, err := p.do(ctx, http.MethodGet, endpoint, nil)
176+
body, header, err := p.do(ctx, http.MethodGet, pageURL(page), nil)
148177
if err != nil {
149178
return out, err
150179
}

internal/vcs/gitlab/gitlab_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,42 @@ func TestListReposFallsBackToComputedCloneURLWhenAPIOmitsIt(t *testing.T) {
9797
}
9898
}
9999

100+
func TestListReposFallsBackToUserNamespace(t *testing.T) {
101+
mux := http.NewServeMux()
102+
mux.HandleFunc("/api/v4/groups/acme/projects", func(w http.ResponseWriter, r *http.Request) {
103+
http.Error(w, `{"message":"404 Group Not Found"}`, http.StatusNotFound)
104+
})
105+
mux.HandleFunc("/api/v4/users/acme/projects", func(w http.ResponseWriter, r *http.Request) {
106+
writeJSON(w, []map[string]any{
107+
{"path": "personal", "path_with_namespace": "acme/personal", "default_branch": "main"},
108+
})
109+
})
110+
111+
p := newProvider(t, mux)
112+
repos, err := p.ListRepos(context.Background())
113+
if err != nil {
114+
t.Fatalf("list: %v", err)
115+
}
116+
if len(repos) != 1 || repos[0].Name != "personal" {
117+
t.Fatalf("unexpected repos: %+v", repos)
118+
}
119+
}
120+
121+
func TestListReposDoesNotFallBackOnNonNotFoundError(t *testing.T) {
122+
mux := http.NewServeMux()
123+
mux.HandleFunc("/api/v4/groups/acme/projects", func(w http.ResponseWriter, r *http.Request) {
124+
http.Error(w, `{"message":"401 Unauthorized"}`, http.StatusUnauthorized)
125+
})
126+
mux.HandleFunc("/api/v4/users/acme/projects", func(w http.ResponseWriter, r *http.Request) {
127+
t.Error("a 401 on the group endpoint must not silently fall back to the user namespace")
128+
})
129+
130+
p := newProvider(t, mux)
131+
if _, err := p.ListRepos(context.Background()); err == nil {
132+
t.Fatal("expected the 401 to be surfaced")
133+
}
134+
}
135+
100136
func TestEnsureRepoLooksUpNamespaceThenCreates(t *testing.T) {
101137
var created map[string]any
102138
mux := http.NewServeMux()

0 commit comments

Comments
 (0)