This repository has no description
1package repo
2
3import (
4 "errors"
5 "fmt"
6 "net/http"
7 "net/url"
8 "slices"
9 "sort"
10 "strings"
11 "sync"
12 "time"
13
14 "context"
15 "encoding/json"
16
17 "github.com/bluesky-social/indigo/atproto/syntax"
18 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19 "github.com/go-git/go-git/v5/plumbing"
20 "tangled.org/core/api/tangled"
21 "tangled.org/core/appview/commitverify"
22 "tangled.org/core/appview/db"
23 "tangled.org/core/appview/models"
24 "tangled.org/core/appview/pages"
25 "tangled.org/core/appview/pages/markup"
26 "tangled.org/core/types"
27
28 "github.com/go-chi/chi/v5"
29 "github.com/go-enry/go-enry/v2"
30)
31
32func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
33 l := rp.logger.With("handler", "RepoIndex")
34
35 ref := chi.URLParam(r, "ref")
36 ref, _ = url.PathUnescape(ref)
37
38 f, err := rp.repoResolver.Resolve(r)
39 if err != nil {
40 l.Error("failed to fully resolve repo", "err", err)
41 return
42 }
43
44 user := rp.oauth.GetMultiAccountUser(r)
45
46 // Build index response from multiple XRPC calls
47 result, err := rp.buildIndexResponse(r.Context(), f, ref)
48 if err != nil {
49 l.Error("failed to build index response", "err", err)
50 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
51 LoggedInUser: user,
52 KnotUnreachable: true,
53 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
54 })
55 return
56 }
57
58 tagMap := make(map[string][]string)
59 for _, tag := range result.Tags {
60 hash := tag.Hash
61 if tag.Tag != nil {
62 hash = tag.Tag.Target.String()
63 }
64 tagMap[hash] = append(tagMap[hash], tag.Name)
65 }
66
67 for _, branch := range result.Branches {
68 hash := branch.Hash
69 tagMap[hash] = append(tagMap[hash], branch.Name)
70 }
71
72 sortFiles(result.Files)
73
74 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
75 if a.Name == result.Ref {
76 return -1
77 }
78 if a.IsDefault {
79 return -1
80 }
81 if b.IsDefault {
82 return 1
83 }
84 if a.Commit != nil && b.Commit != nil {
85 if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
86 return 1
87 } else {
88 return -1
89 }
90 }
91 return strings.Compare(a.Name, b.Name) * -1
92 })
93
94 commitCount := len(result.Commits)
95 branchCount := len(result.Branches)
96 tagCount := len(result.Tags)
97 fileCount := len(result.Files)
98
99 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
100 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
101 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
102 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
103
104 emails := uniqueEmails(commitsTrunc)
105 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
106 if err != nil {
107 l.Error("failed to get email to did map", "err", err)
108 }
109
110 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, commitsTrunc)
111 if err != nil {
112 l.Error("failed to GetVerifiedObjectCommits", "err", err)
113 }
114
115 var languageInfo []types.RepoLanguageDetails
116 if !result.IsEmpty {
117 langs, err := rp.getLanguageInfo(r.Context(), syntax.DID(f.RepoDid), result.Ref)
118 if err != nil {
119 l.Warn("failed to compute language percentages", "err", err)
120 // non-fatal
121 } else if ref == "" { // when request didn't specified ref, we are fetching default branch.
122 if err := func(repo syntax.DID, ref string, langs []*tangled.GitTempListLanguages_Language) error {
123 tx, err := rp.db.Begin()
124 if err != nil {
125 return err
126 }
127 defer tx.Rollback()
128
129 var mlangs []models.RepoLanguage
130 for _, lang := range langs {
131 mlangs = append(mlangs, models.RepoLanguage{
132 RepoDid: repo,
133 Ref: ref,
134 IsDefaultRef: true,
135 Language: lang.Name,
136 Bytes: lang.Size,
137 })
138 }
139
140 if err := db.UpdateRepoLanguages(tx, syntax.DID(f.RepoDid), ref, mlangs); err != nil {
141 return err
142 }
143
144 return tx.Commit()
145 }(syntax.DID(f.RepoDid), result.Ref, langs); err != nil {
146 l.Error("failed to populate appview repo languages index", "err", err)
147 // non-fatal
148 }
149 languageInfo = makeLanguageStats(langs)
150 }
151 }
152
153 var shas []string
154 for _, c := range commitsTrunc {
155 shas = append(shas, c.Hash.String())
156 }
157 pipelines, err := getPipelineStatuses(rp.db, f, shas)
158 if err != nil {
159 l.Error("failed to fetch pipeline statuses", "err", err)
160 // non-fatal
161 }
162
163 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
164 LoggedInUser: user,
165 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
166 TagMap: tagMap,
167 RepoIndexResponse: *result,
168 CommitsTrunc: commitsTrunc,
169 TagsTrunc: tagsTrunc,
170 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
171 BranchesTrunc: branchesTrunc,
172 EmailToDid: emailToDidMap,
173 VerifiedCommits: vc,
174 Languages: languageInfo,
175 Pipelines: pipelines,
176 })
177}
178
179func (rp *Repo) getLanguageInfo(
180 ctx context.Context,
181 repoId syntax.DID,
182 ref string,
183) ([]*tangled.GitTempListLanguages_Language, error) {
184 // non-fatal, fetch langs from knotmirror via XRPC
185 xrpcc := &indigoxrpc.Client{
186 Host: rp.config.KnotMirror.Url,
187 Client: http.DefaultClient,
188 }
189 out, err := tangled.GitTempListLanguages(ctx, xrpcc, ref, repoId.String())
190 if err != nil {
191 return nil, fmt.Errorf("calling knotmirror git.listLanguages: %w", err)
192 }
193
194 if out == nil || out.Languages == nil {
195 return nil, nil
196 }
197
198 return out.Languages, nil
199}
200
201func makeLanguageStats(langs []*tangled.GitTempListLanguages_Language) []types.RepoLanguageDetails {
202 if len(langs) == 0 {
203 return nil
204 }
205 var total int64
206 for _, lang := range langs {
207 total += lang.Size
208 }
209
210 var languageStats []types.RepoLanguageDetails
211 for _, l := range langs {
212 languageStats = append(languageStats, types.RepoLanguageDetails{
213 Name: l.Name,
214 Color: enry.GetColor(l.Name),
215 Percentage: float32(l.Size) / float32(total) * 100,
216 })
217 }
218
219 sort.Slice(languageStats, func(i, j int) bool {
220 if languageStats[i].Name == enry.OtherLanguage {
221 return false
222 }
223 if languageStats[j].Name == enry.OtherLanguage {
224 return true
225 }
226 if languageStats[i].Percentage != languageStats[j].Percentage {
227 return languageStats[i].Percentage > languageStats[j].Percentage
228 }
229 return languageStats[i].Name < languageStats[j].Name
230 })
231 return languageStats
232}
233
234// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel
235func (rp *Repo) buildIndexResponse(ctx context.Context, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) {
236 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url}
237
238 branchesBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid)
239 if err != nil {
240 return nil, fmt.Errorf("calling knotmirror git.listBranches: %w", err)
241 }
242
243 var branchesResp types.RepoBranchesResponse
244 if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
245 return nil, fmt.Errorf("failed to unmarshal branches response: %w", err)
246 }
247
248 // if no ref specified, use default branch or first available
249 if ref == "" {
250 for _, branch := range branchesResp.Branches {
251 if branch.IsDefault {
252 ref = branch.Name
253 break
254 }
255 }
256 }
257
258 // if ref is still empty, this means the default branch is not set
259 if ref == "" {
260 return &types.RepoIndexResponse{
261 IsEmpty: true,
262 Branches: branchesResp.Branches,
263 }, nil
264 }
265
266 // now run the remaining queries in parallel
267 var wg sync.WaitGroup
268 var errs error
269
270 var (
271 tagsResp types.RepoTagsResponse
272 treeResp *tangled.GitTempGetTree_Output
273 logResp types.RepoLogResponse
274 readmeContent string
275 readmeFileName string
276 )
277
278 // tags
279 wg.Go(func() {
280 tagsBytes, err := tangled.GitTempListTags(ctx, xrpcc, "", 0, repo.RepoDid)
281 if err != nil {
282 errs = errors.Join(errs, fmt.Errorf("failed to call git.ListTags: %w", err))
283 return
284 }
285
286 if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
287 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListTags: %w", err))
288 }
289 })
290
291 // tree/files
292 wg.Go(func() {
293 resp, err := tangled.GitTempGetTree(ctx, xrpcc, "", ref, repo.RepoDid)
294 if err != nil {
295 errs = errors.Join(errs, fmt.Errorf("failed to call git.GetTree: %w", err))
296 return
297 }
298 treeResp = resp
299
300 for _, file := range resp.Files {
301 if markup.IsReadmeFile(file.Name, file.Mode) {
302 readmeFileName = file.Name
303 break
304 }
305 }
306
307 if readmeFileName != "" {
308 bytes, err := tangled.GitTempGetBlob(ctx, xrpcc, readmeFileName, ref, repo.RepoDid)
309 if err != nil {
310 errs = errors.Join(errs, fmt.Errorf("failed to call git.getBlob: %w", err))
311 return
312 }
313 readmeContent = string(bytes)
314 }
315 })
316
317 // commits
318 wg.Go(func() {
319 logBytes, err := tangled.GitTempListCommits(ctx, xrpcc, "", 50, ref, repo.RepoDid)
320 if err != nil {
321 errs = errors.Join(errs, fmt.Errorf("failed to call git.ListCommits: %w", err))
322 return
323 }
324
325 if err := json.Unmarshal(logBytes, &logResp); err != nil {
326 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListCommits: %w", err))
327 }
328 })
329
330 wg.Wait()
331
332 if errs != nil {
333 return nil, errs
334 }
335
336 var files []types.NiceTree
337 if treeResp != nil && treeResp.Files != nil {
338 for _, file := range treeResp.Files {
339 niceFile := types.NiceTree{
340 Name: file.Name,
341 Mode: file.Mode,
342 Size: file.Size,
343 }
344
345 if file.Last_commit != nil {
346 when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
347 niceFile.LastCommit = &types.LastCommitInfo{
348 Hash: plumbing.NewHash(file.Last_commit.Hash),
349 Message: file.Last_commit.Message,
350 When: when,
351 }
352 }
353 files = append(files, niceFile)
354 }
355 }
356
357 result := &types.RepoIndexResponse{
358 IsEmpty: false,
359 Ref: ref,
360 Readme: readmeContent,
361 ReadmeFileName: readmeFileName,
362 Commits: logResp.Commits,
363 Description: "",
364 Files: files,
365 Branches: branchesResp.Branches,
366 Tags: tagsResp.Tags,
367 TotalCommits: logResp.Total,
368 }
369
370 return result, nil
371}