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