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