this repo has no description
1package repo
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "net/http"
8 "slices"
9 "sort"
10 "strings"
11 "sync"
12 "time"
13
14 "context"
15 "encoding/json"
16
17 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
18 "github.com/go-git/go-git/v5/plumbing"
19 "tangled.sh/tangled.sh/core/api/tangled"
20 "tangled.sh/tangled.sh/core/appview/commitverify"
21 "tangled.sh/tangled.sh/core/appview/db"
22 "tangled.sh/tangled.sh/core/appview/pages"
23 "tangled.sh/tangled.sh/core/appview/pages/markup"
24 "tangled.sh/tangled.sh/core/appview/reporesolver"
25 "tangled.sh/tangled.sh/core/appview/xrpcclient"
26 "tangled.sh/tangled.sh/core/types"
27
28 "github.com/go-chi/chi/v5"
29 "github.com/go-enry/go-enry/v2"
30)
31
32func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
33 ref := chi.URLParam(r, "ref")
34
35 f, err := rp.repoResolver.Resolve(r)
36 if err != nil {
37 log.Println("failed to fully resolve repo", err)
38 return
39 }
40
41 scheme := "http"
42 if !rp.config.Core.Dev {
43 scheme = "https"
44 }
45 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
46 xrpcc := &indigoxrpc.Client{
47 Host: host,
48 }
49
50 var needsKnotUpgrade bool
51 // Build index response from multiple XRPC calls
52 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
53 if err != nil {
54 if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
55 log.Println("failed to call XRPC repo.index", err)
56 needsKnotUpgrade = true
57 return
58 }
59
60 rp.pages.Error503(w)
61 log.Println("failed to build index response", err)
62 return
63 }
64
65 tagMap := make(map[string][]string)
66 for _, tag := range result.Tags {
67 hash := tag.Hash
68 if tag.Tag != nil {
69 hash = tag.Tag.Target.String()
70 }
71 tagMap[hash] = append(tagMap[hash], tag.Name)
72 }
73
74 for _, branch := range result.Branches {
75 hash := branch.Hash
76 tagMap[hash] = append(tagMap[hash], branch.Name)
77 }
78
79 sortFiles(result.Files)
80
81 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
82 if a.Name == result.Ref {
83 return -1
84 }
85 if a.IsDefault {
86 return -1
87 }
88 if b.IsDefault {
89 return 1
90 }
91 if a.Commit != nil && b.Commit != nil {
92 if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
93 return 1
94 } else {
95 return -1
96 }
97 }
98 return strings.Compare(a.Name, b.Name) * -1
99 })
100
101 commitCount := len(result.Commits)
102 branchCount := len(result.Branches)
103 tagCount := len(result.Tags)
104 fileCount := len(result.Files)
105
106 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
107 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
108 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
109 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
110
111 emails := uniqueEmails(commitsTrunc)
112 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
113 if err != nil {
114 log.Println("failed to get email to did map", err)
115 }
116
117 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
118 if err != nil {
119 log.Println(err)
120 }
121
122 user := rp.oauth.GetUser(r)
123 repoInfo := f.RepoInfo(user)
124
125 // TODO: a bit dirty
126 languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
127 if err != nil {
128 log.Printf("failed to compute language percentages: %s", err)
129 // non-fatal
130 }
131
132 var shas []string
133 for _, c := range commitsTrunc {
134 shas = append(shas, c.Hash.String())
135 }
136 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
137 if err != nil {
138 log.Printf("failed to fetch pipeline statuses: %s", err)
139 // non-fatal
140 }
141
142 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
143 LoggedInUser: user,
144 NeedsKnotUpgrade: needsKnotUpgrade,
145 RepoInfo: repoInfo,
146 TagMap: tagMap,
147 RepoIndexResponse: *result,
148 CommitsTrunc: commitsTrunc,
149 TagsTrunc: tagsTrunc,
150 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
151 BranchesTrunc: branchesTrunc,
152 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
153 VerifiedCommits: vc,
154 Languages: languageInfo,
155 Pipelines: pipelines,
156 })
157}
158
159func (rp *Repo) getLanguageInfo(
160 ctx context.Context,
161 f *reporesolver.ResolvedRepo,
162 xrpcc *indigoxrpc.Client,
163 currentRef string,
164 isDefaultRef bool,
165) ([]types.RepoLanguageDetails, error) {
166 // first attempt to fetch from db
167 langs, err := db.GetRepoLanguages(
168 rp.db,
169 db.FilterEq("repo_at", f.RepoAt()),
170 db.FilterEq("ref", currentRef),
171 )
172
173 if err != nil || langs == nil {
174 // non-fatal, fetch langs from ks via XRPC
175 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
176 ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
177 if err != nil {
178 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
179 log.Println("failed to call XRPC repo.languages", xrpcerr)
180 return nil, xrpcerr
181 }
182 return nil, err
183 }
184
185 if ls == nil || ls.Languages == nil {
186 return nil, nil
187 }
188
189 for _, lang := range ls.Languages {
190 langs = append(langs, db.RepoLanguage{
191 RepoAt: f.RepoAt(),
192 Ref: currentRef,
193 IsDefaultRef: isDefaultRef,
194 Language: lang.Name,
195 Bytes: lang.Size,
196 })
197 }
198
199 // update appview's cache
200 err = db.InsertRepoLanguages(rp.db, langs)
201 if err != nil {
202 // non-fatal
203 log.Println("failed to cache lang results", err)
204 }
205 }
206
207 var total int64
208 for _, l := range langs {
209 total += l.Bytes
210 }
211
212 var languageStats []types.RepoLanguageDetails
213 for _, l := range langs {
214 percentage := float32(l.Bytes) / float32(total) * 100
215 color := enry.GetColor(l.Language)
216 languageStats = append(languageStats, types.RepoLanguageDetails{
217 Name: l.Language,
218 Percentage: percentage,
219 Color: color,
220 })
221 }
222
223 sort.Slice(languageStats, func(i, j int) bool {
224 if languageStats[i].Name == enry.OtherLanguage {
225 return false
226 }
227 if languageStats[j].Name == enry.OtherLanguage {
228 return true
229 }
230 if languageStats[i].Percentage != languageStats[j].Percentage {
231 return languageStats[i].Percentage > languageStats[j].Percentage
232 }
233 return languageStats[i].Name < languageStats[j].Name
234 })
235
236 return languageStats, nil
237}
238
239// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel
240func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) {
241 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
242
243 // first get branches to determine the ref if not specified
244 branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
245 if err != nil {
246 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
247 log.Println("failed to call XRPC repo.branches", xrpcerr)
248 return nil, xrpcerr
249 }
250 return nil, err
251 }
252
253 var branchesResp types.RepoBranchesResponse
254 if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
255 return nil, err
256 }
257
258 // if no ref specified, use default branch or first available
259 if ref == "" && len(branchesResp.Branches) > 0 {
260 for _, branch := range branchesResp.Branches {
261 if branch.IsDefault {
262 ref = branch.Name
263 break
264 }
265 }
266 if ref == "" {
267 ref = branchesResp.Branches[0].Name
268 }
269 }
270
271 // check if repo is empty
272 if len(branchesResp.Branches) == 0 {
273 return &types.RepoIndexResponse{
274 IsEmpty: true,
275 Branches: branchesResp.Branches,
276 }, nil
277 }
278
279 // now run the remaining queries in parallel
280 var wg sync.WaitGroup
281 var mu sync.Mutex
282 var errs []error
283
284 var (
285 tagsResp types.RepoTagsResponse
286 treeResp *tangled.RepoTree_Output
287 logResp types.RepoLogResponse
288 readmeContent string
289 readmeFileName string
290 )
291
292 // tags
293 wg.Add(1)
294 go func() {
295 defer wg.Done()
296 tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
297 if err != nil {
298 mu.Lock()
299 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
300 log.Println("failed to call XRPC repo.tags", xrpcerr)
301 errs = append(errs, xrpcerr)
302 } else {
303 errs = append(errs, err)
304 }
305 mu.Unlock()
306 return
307 }
308
309 if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
310 mu.Lock()
311 errs = append(errs, err)
312 mu.Unlock()
313 }
314 }()
315
316 // tree/files
317 wg.Add(1)
318 go func() {
319 defer wg.Done()
320 resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
321 if err != nil {
322 mu.Lock()
323 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
324 log.Println("failed to call XRPC repo.tree", xrpcerr)
325 errs = append(errs, xrpcerr)
326 } else {
327 errs = append(errs, err)
328 }
329 mu.Unlock()
330 return
331 }
332 treeResp = resp
333 }()
334
335 // commits
336 wg.Add(1)
337 go func() {
338 defer wg.Done()
339 logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
340 if err != nil {
341 mu.Lock()
342 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
343 log.Println("failed to call XRPC repo.log", xrpcerr)
344 errs = append(errs, xrpcerr)
345 } else {
346 errs = append(errs, err)
347 }
348 mu.Unlock()
349 return
350 }
351
352 if err := json.Unmarshal(logBytes, &logResp); err != nil {
353 mu.Lock()
354 errs = append(errs, err)
355 mu.Unlock()
356 }
357 }()
358
359 // readme content
360 wg.Add(1)
361 go func() {
362 defer wg.Done()
363 for _, filename := range markup.ReadmeFilenames {
364 blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
365 if err != nil {
366 continue
367 }
368
369 if blobResp == nil {
370 continue
371 }
372
373 readmeContent = blobResp.Content
374 readmeFileName = filename
375 break
376 }
377 }()
378
379 wg.Wait()
380
381 if len(errs) > 0 {
382 return nil, errs[0] // return first error
383 }
384
385 var files []types.NiceTree
386 if treeResp != nil && treeResp.Files != nil {
387 for _, file := range treeResp.Files {
388 niceFile := types.NiceTree{
389 IsFile: file.Is_file,
390 IsSubtree: file.Is_subtree,
391 Name: file.Name,
392 Mode: file.Mode,
393 Size: file.Size,
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 Description: logResp.Description,
414 Files: files,
415 Branches: branchesResp.Branches,
416 Tags: tagsResp.Tags,
417 TotalCommits: logResp.Total,
418 }
419
420 return result, nil
421}