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