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