Repository navigation
Expand file tree
/
Copy pathgithub.go
More file actions
255 lines (228 loc) · 7.56 KB
/
Copy pathgithub.go
File metadata and controls
255 lines (228 loc) · 7.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
package main
import (
"cmp"
"context"
"errors"
"fmt"
"log"
"net/http"
"slices"
"time"
"github.com/google/go-github/v90/github"
"github.com/sourcegraph/conc/pool"
)
const (
// repositoriesCount is const for allocation memory to store repositories
repositoriesCount = 1000
// langReposCount is const for allocation memory to store langRepo
langReposCount = 100
)
// GitHub struct for requests
type GitHub struct {
client *github.Client
}
// Repository struct for storing parameters from Repository
type Repository struct {
FullName string
URL string
Language string
Description string
}
// httpClientTimeout bounds a single API request so a stalled connection
// cannot hang the process forever.
const httpClientTimeout = 30 * time.Second
func newHTTPClient() *http.Client {
return &http.Client{Timeout: httpClientTimeout}
}
// New creates new GitHub client
func New(token string) (*GitHub, error) {
opts := []github.ClientOptionsFunc{github.WithHTTPClient(newHTTPClient())}
if token != "" {
opts = append(opts, github.WithAuthToken(token))
}
gh, err := github.NewClient(opts...)
if err != nil {
return nil, err
}
return &GitHub{client: gh}, nil
}
// GetRepositories getting repositories from GitHub
func (g *GitHub) GetRepositories(ctx context.Context) (map[string][]Repository, []Repository, error) {
repositories := make([]Repository, 0, repositoriesCount)
langRepoMap := make(map[string][]Repository, langReposCount)
opt := func(page int) *github.ActivityListStarredOptions {
return &github.ActivityListStarredOptions{
ListOptions: github.ListOptions{
PerPage: 100,
Page: page,
},
}
}
repos, resp, err := g.fetchStarredPage(ctx, username, opt(1))
if err != nil {
return nil, nil, fmt.Errorf("cannot fetch starred: %w", err)
}
// https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28
// No more than 100 concurrent requests are allowed. This limit is shared across the REST API and GraphQL API.
// We use a pool to limit the number of concurrent requests with a maximum of 90 goroutines.
const concurrentLimits = 90
p := pool.NewWithResults[[]*github.StarredRepository]().
WithMaxGoroutines(concurrentLimits).
WithContext(ctx).
WithCancelOnError().
WithFirstError()
for i := 2; i <= resp.LastPage; i++ {
page := i
p.Go(func(ctx context.Context) ([]*github.StarredRepository, error) {
githubRepos, _, err := g.fetchStarredPage(ctx, username, opt(page))
if err != nil {
return nil, err
}
return githubRepos, nil
})
}
githubRepos, err := p.Wait()
if err != nil {
return nil, nil, err
}
for _, r := range githubRepos {
repos = append(repos, r...)
}
for _, r := range repos {
repo := Repository{
FullName: r.Repository.GetFullName(),
URL: r.Repository.GetHTMLURL(),
Language: r.Repository.GetLanguage(),
Description: r.Repository.GetDescription(),
}
repositories = append(repositories, repo)
lang := repo.Language
if lang == "" {
lang = "Others"
}
if alias, ok := langAliases[lang]; ok {
lang = alias
}
if _, ok := langRepoMap[lang]; !ok {
langRepoMap[lang] = make([]Repository, 0, langReposCount)
}
langRepoMap[lang] = append(langRepoMap[lang], repo)
}
if len(repositories) == 0 {
return langRepoMap, repositories, nil
}
slices.SortFunc(repositories, func(a, b Repository) int {
return cmp.Compare(a.FullName, b.FullName)
})
for _, repositories := range langRepoMap {
slices.SortFunc(repositories, func(a, b Repository) int {
return cmp.Compare(a.FullName, b.FullName)
})
}
return langRepoMap, repositories, nil
}
// langAliases merges stale language names that the GitHub API still returns
// for repositories classified before a linguist rename. Entries are added only
// with evidence: a name returned by the API that is absent from current
// linguist languages.yml. Verified against juev's starred list on 2026-08-19:
// every other language matches current linguist verbatim.
var langAliases = map[string]string{
"Vim script": "Vim Script",
"VimL": "Vim Script",
}
// fetchStarredPage fetches one page of starred repositories. When the remaining
// rate limit quota is nearly exhausted, it waits for the limit to reset and
// retries the page instead of failing. The response of the successful fetch is
// returned so callers can read pagination info.
func (g *GitHub) fetchStarredPage(
ctx context.Context,
username string,
opts *github.ActivityListStarredOptions) ([]*github.StarredRepository, *github.Response, error) {
for {
repos, resp, err := g.client.Activity.ListStarred(ctx, username, opts)
if err != nil {
return nil, nil, err
}
if resp == nil {
return nil, nil, fmt.Errorf("cannot fetch starred: empty response")
}
if resp.Rate.Remaining < 10 {
if wait := time.Until(resp.Rate.Reset.Time); wait > 0 {
log.Default().Printf("rate limit nearly exhausted, waiting %s until reset", wait.Truncate(time.Second))
if err := sleepContext(ctx, wait); err != nil {
return nil, nil, err
}
continue
}
}
return repos, resp, nil
}
}
func sleepContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// UpdateRequest describes the README.md update to perform.
type UpdateRequest struct {
Owner string
Repo string
Message string
Content []byte
}
// UpdateReadmeFile creates or updates README.md in the given repository. If
// the file changed between reading and updating (409 Conflict), it re-reads
// the SHA and retries the update once.
func (g *GitHub) UpdateReadmeFile(ctx context.Context, req UpdateRequest) error {
if _, _, err := g.client.Repositories.Get(ctx, req.Owner, req.Repo); err != nil {
return fmt.Errorf("cannot check repository %s/%s exists: %w", req.Owner, req.Repo, err)
}
readmeFile, _, resp, err := g.client.Repositories.GetContents(ctx, req.Owner, req.Repo, "README.md", &github.RepositoryContentGetOptions{})
// if file does not exist, just create it
if err != nil || resp == nil || resp.StatusCode != http.StatusOK {
if _, _, err := g.client.Repositories.CreateFile(ctx, req.Owner, req.Repo, "README.md", &github.RepositoryContentFileOptions{
Message: &req.Message,
Content: req.Content,
}); err != nil {
return fmt.Errorf("cannot create README.md: %w", err)
}
return nil
}
// if file exists, update it
if err := g.updateReadme(ctx, req, readmeFile.GetSHA()); err != nil {
return err
}
return nil
}
func (g *GitHub) updateReadme(ctx context.Context, req UpdateRequest, sha string) error {
_, _, err := g.client.Repositories.UpdateFile(ctx, req.Owner, req.Repo, "README.md", &github.RepositoryContentFileOptions{
Message: &req.Message,
Content: req.Content,
SHA: &sha,
})
if err == nil {
return nil
}
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) || ghErr.Response == nil || ghErr.Response.StatusCode != http.StatusConflict {
return fmt.Errorf("cannot update README.md: %w", err)
}
// the file changed between read and update: re-read the SHA and retry once
readmeFile, _, _, err := g.client.Repositories.GetContents(ctx, req.Owner, req.Repo, "README.md", &github.RepositoryContentGetOptions{})
if err != nil {
return fmt.Errorf("cannot re-read README.md after conflict: %w", err)
}
if _, _, err := g.client.Repositories.UpdateFile(ctx, req.Owner, req.Repo, "README.md", &github.RepositoryContentFileOptions{
Message: &req.Message,
Content: req.Content,
SHA: readmeFile.SHA,
}); err != nil {
return fmt.Errorf("cannot update README.md: %w", err)
}
return nil
}