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