From 7acace3560d32ba71630bec2fce9d55b1dcc671b Mon Sep 17 00:00:00 2001 From: Michael Gebetsroither Date: Wed, 14 Oct 2026 12:10:08 +0200 Subject: [PATCH 1/4] Add organization option to back up public repos of an org/group Lists all public repositories of the given GitHub organizations or GitLab groups (including subgroups), also when not a member. Results are merged with the other discovery mechanisms and filtered by exclude. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++++++++++++++ github.go | 26 ++++++++++++++++++++++++++ gitlab.go | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/README.md b/README.md index 1c993bb..5d68d5a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ github: # (default: []) repositories: - ChappIO/git-backup + # (optional) Backup all public repositories of these + # organizations, even if you are not a member. + # Will be merged with the other discovery mechanisms + # and it's result filtered with excluded repos. + # (default: []) + organization: + - golang # (optional) Exclude this list of repos # or whole organizations/users exclude: @@ -85,6 +92,13 @@ gitlab: repositories: - gnuwget/wget2 - gitlab-org/api/client-go + # (optional) Backup all public projects of these groups + # (including subgroups), even if you are not a member. + # Will be merged with the other discovery mechanisms + # and it's result filtered with excluded repos. + # (default: []) + organization: + - gnuwget # (optional) Exclude this list of repos # or whole organizations/users exclude: diff --git a/github.go b/github.go index 196bb19..4223e3e 100644 --- a/github.go +++ b/github.go @@ -23,6 +23,7 @@ type GithubConfig struct { Owned *bool `yaml:"owned,omitempty"` Exclude []string `yaml:"exclude,omitempty"` Repositories []string `yaml:"repositories,omitempty"` + Organization []string `yaml:"organization,omitempty"` client *github.Client } @@ -116,6 +117,21 @@ func (c *GithubConfig) getAllRepos() ([]*github.Repository, error) { // use discovery mechanisms var err error + for _, org := range c.Organization { + for repos, response, apiErr := c.getOrgRepos(org, 1); true; repos, response, apiErr = c.getOrgRepos(org, response.NextPage) { + if apiErr != nil { + return nil, apiErr + } + for _, repo := range repos { + all[*repo.FullName] = repo + } + + if len(repos) == 0 || response.NextPage == 0 { + break + } + } + } + for repos, response, apiErr := c.discoverRepos(1); true; repos, response, apiErr = c.discoverRepos(response.NextPage) { if apiErr != nil { err = apiErr @@ -175,6 +191,16 @@ func (c *GithubConfig) getRepoList(repos []string) ([]*github.Repository, error) return ghRepos, nil } +func (c *GithubConfig) getOrgRepos(org string, page int) ([]*github.Repository, *github.Response, error) { + return c.client.Repositories.ListByOrg(context.Background(), org, &github.RepositoryListByOrgOptions{ + Type: "public", + ListOptions: github.ListOptions{ + Page: page, + PerPage: 100, + }, + }) +} + func (c *GithubConfig) discoverRepos(page int) ([]*github.Repository, *github.Response, error) { affiliations := make([]string, 0) diff --git a/gitlab.go b/gitlab.go index f7ad988..891f127 100644 --- a/gitlab.go +++ b/gitlab.go @@ -18,6 +18,7 @@ type GitLabConfig struct { Owned *bool `yaml:"owned,omitempty"` Exclude []string `yaml:"exclude,omitempty"` Repositories []string `yaml:"repositories,omitempty"` + Organization []string `yaml:"organization,omitempty"` client *gitlab.Client } @@ -101,6 +102,16 @@ func (g *GitLabConfig) getAllRepos() ([]*gitlab.Project, error) { } // use discovery mechanisms + for _, group := range g.Organization { + if repos, err := g.getGroupRepos(group); err != nil { + return nil, err + } else { + for _, repo := range repos { + all[repo.PathWithNamespace] = repo + } + } + } + if *g.Starred { if repos, err := g.discoverRepos(&gitlab.ListProjectsOptions{Starred: boolPointer(true)}); err != nil { return nil, err @@ -149,6 +160,32 @@ func (g *GitLabConfig) getRepoList(repos []string) ([]*gitlab.Project, error) { } +func (g *GitLabConfig) getGroupRepos(group string) ([]*gitlab.Project, error) { + opts := &gitlab.ListGroupProjectsOptions{ + IncludeSubGroups: boolPointer(true), + Simple: boolPointer(true), + Visibility: gitlab.Ptr(gitlab.PublicVisibility), + } + opts.ListOptions.PerPage = 100 + + repos := make([]*gitlab.Project, 0, 100) + + // paginate + for i := 1; true; i++ { + opts.ListOptions.Page = i + projects, _, err := g.client.Groups.ListGroupProjects(group, opts) + if err != nil { + return nil, err + } + + if len(projects) == 0 { + break + } + repos = append(repos, projects...) + } + return repos, nil +} + func (g *GitLabConfig) discoverRepos(opts *gitlab.ListProjectsOptions) ([]*gitlab.Project, error) { opts.ListOptions.PerPage = 100 From 1f48e69a3054e6a11ae4a2f2c1c4640fe2f80f95 Mon Sep 17 00:00:00 2001 From: Michael Gebetsroither Date: Wed, 4 Nov 2026 17:03:09 +0100 Subject: [PATCH 2/4] Support user accounts in the organization option /orgs/{name}/repos returns 404 for user accounts. Use /users/{name}/repos instead, which lists public repos of both organizations and users. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- github.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5d68d5a..33d1d29 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ github: repositories: - ChappIO/git-backup # (optional) Backup all public repositories of these - # organizations, even if you are not a member. + # organizations or users, even if you are not a member. # Will be merged with the other discovery mechanisms # and it's result filtered with excluded repos. # (default: []) diff --git a/github.go b/github.go index 4223e3e..073e02a 100644 --- a/github.go +++ b/github.go @@ -192,8 +192,9 @@ func (c *GithubConfig) getRepoList(repos []string) ([]*github.Repository, error) } func (c *GithubConfig) getOrgRepos(org string, page int) ([]*github.Repository, *github.Response, error) { - return c.client.Repositories.ListByOrg(context.Background(), org, &github.RepositoryListByOrgOptions{ - Type: "public", + // /users/{name}/repos works for both organizations and user accounts, /orgs/{name}/repos only for organizations + return c.client.Repositories.List(context.Background(), org, &github.RepositoryListOptions{ + Type: "owner", ListOptions: github.ListOptions{ Page: page, PerPage: 100, From 772861e3b4cf5d8bbc6dcbb8ed6155a26a118b32 Mon Sep 17 00:00:00 2001 From: Michael Gebetsroither Date: Wed, 4 Nov 2026 17:11:37 +0100 Subject: [PATCH 3/4] Support user accounts in the GitLab organization option /groups/{id}/projects returns 404 for user namespaces. Fall back to /users/{id}/projects in that case, and name the failing entry in the error. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- gitlab.go | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 33d1d29..df33534 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ gitlab: - gnuwget/wget2 - gitlab-org/api/client-go # (optional) Backup all public projects of these groups - # (including subgroups), even if you are not a member. + # (including subgroups) or users, even if you are not a member. # Will be merged with the other discovery mechanisms # and it's result filtered with excluded repos. # (default: []) diff --git a/gitlab.go b/gitlab.go index 891f127..7122f0b 100644 --- a/gitlab.go +++ b/gitlab.go @@ -1,6 +1,8 @@ package git_backup import ( + "errors" + "fmt" "log" "maps" "net/url" @@ -103,8 +105,13 @@ func (g *GitLabConfig) getAllRepos() ([]*gitlab.Project, error) { // use discovery mechanisms for _, group := range g.Organization { - if repos, err := g.getGroupRepos(group); err != nil { - return nil, err + repos, err := g.getGroupRepos(group) + // groups and users live in separate API namespaces, a user name 404s as a group + if errors.Is(err, gitlab.ErrNotFound) { + repos, err = g.getUserRepos(group) + } + if err != nil { + return nil, fmt.Errorf("organization %q: %w", group, err) } else { for _, repo := range repos { all[repo.PathWithNamespace] = repo @@ -186,6 +193,31 @@ func (g *GitLabConfig) getGroupRepos(group string) ([]*gitlab.Project, error) { return repos, nil } +func (g *GitLabConfig) getUserRepos(user string) ([]*gitlab.Project, error) { + opts := &gitlab.ListProjectsOptions{ + Simple: boolPointer(true), + Visibility: gitlab.Ptr(gitlab.PublicVisibility), + } + opts.ListOptions.PerPage = 100 + + repos := make([]*gitlab.Project, 0, 100) + + // paginate + for i := int64(1); true; i++ { + opts.ListOptions.Page = i + projects, _, err := g.client.Projects.ListUserProjects(user, opts) + if err != nil { + return nil, err + } + + if len(projects) == 0 { + break + } + repos = append(repos, projects...) + } + return repos, nil +} + func (g *GitLabConfig) discoverRepos(opts *gitlab.ListProjectsOptions) ([]*gitlab.Project, error) { opts.ListOptions.PerPage = 100 From e30c55a0d768358c124a098528b24b81e8686195 Mon Sep 17 00:00:00 2001 From: Michael Gebetsroither Date: Wed, 4 Nov 2026 17:12:08 +0100 Subject: [PATCH 4/4] Use int page counter to match client-go v0.157.0 Co-Authored-By: Claude Opus 5.5 --- gitlab.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab.go b/gitlab.go index 7122f0b..8f34a50 100644 --- a/gitlab.go +++ b/gitlab.go @@ -203,7 +203,7 @@ func (g *GitLabConfig) getUserRepos(user string) ([]*gitlab.Project, error) { repos := make([]*gitlab.Project, 0, 100) // paginate - for i := int64(1); true; i++ { + for i := 1; true; i++ { opts.ListOptions.Page = i projects, _, err := g.client.Projects.ListUserProjects(user, opts) if err != nil {