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