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{Host: rp.config.KnotMirror.Url}
168 ls, err := tangled.GitTempListLanguages(ctx, xrpcc, currentRef, repo.RepoAt().String())
169 if err != nil {
170 return nil, fmt.Errorf("calling knotmirror git.listLanguages: %w", err)
171 }
172
173 if ls == nil || ls.Languages == nil {
174 return nil, nil
175 }
176
177 for _, lang := range ls.Languages {
178 langs = append(langs, models.RepoLanguage{
179 RepoAt: repo.RepoAt(),
180 Ref: currentRef,
181 IsDefaultRef: isDefaultRef,
182 Language: lang.Name,
183 Bytes: lang.Size,
184 })
185 }
186
187 tx, err := rp.db.Begin()
188 if err != nil {
189 return nil, err
190 }
191 defer tx.Rollback()
192
193 // update appview's cache
194 err = db.UpdateRepoLanguages(tx, repo.RepoAt(), currentRef, langs)
195 if err != nil {
196 // non-fatal
197 l.Error("failed to cache lang results", "err", err)
198 }
199
200 err = tx.Commit()
201 if err != nil {
202 return nil, err
203 }
204 }
205
206 var total int64
207 for _, l := range langs {
208 total += l.Bytes
209 }
210
211 var languageStats []types.RepoLanguageDetails
212 for _, l := range langs {
213 percentage := float32(l.Bytes) / float32(total) * 100
214 color := enry.GetColor(l.Language)
215 languageStats = append(languageStats, types.RepoLanguageDetails{
216 Name: l.Language,
217 Percentage: percentage,
218 Color: color,
219 })
220 }
221
222 sort.Slice(languageStats, func(i, j int) bool {
223 if languageStats[i].Name == enry.OtherLanguage {
224 return false
225 }
226 if languageStats[j].Name == enry.OtherLanguage {
227 return true
228 }
229 if languageStats[i].Percentage != languageStats[j].Percentage {
230 return languageStats[i].Percentage > languageStats[j].Percentage
231 }
232 return languageStats[i].Name < languageStats[j].Name
233 })
234
235 return languageStats, nil
236}
237
238// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel
239func (rp *Repo) buildIndexResponse(ctx context.Context, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) {
240 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url}
241
242 branchesBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoAt().String())
243 if err != nil {
244 return nil, fmt.Errorf("calling knotmirror git.listBranches: %w", err)
245 }
246
247 var branchesResp types.RepoBranchesResponse
248 if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
249 return nil, fmt.Errorf("failed to unmarshal branches response: %w", err)
250 }
251
252 // if no ref specified, use default branch or first available
253 if ref == "" {
254 for _, branch := range branchesResp.Branches {
255 if branch.IsDefault {
256 ref = branch.Name
257 break
258 }
259 }
260 }
261
262 // if ref is still empty, this means the default branch is not set
263 if ref == "" {
264 return &types.RepoIndexResponse{
265 IsEmpty: true,
266 Branches: branchesResp.Branches,
267 }, nil
268 }
269
270 // now run the remaining queries in parallel
271 var wg sync.WaitGroup
272 var errs error
273
274 var (
275 tagsResp types.RepoTagsResponse
276 treeResp *tangled.GitTempGetTree_Output
277 logResp types.RepoLogResponse
278 readmeContent string
279 readmeFileName string
280 )
281
282 // tags
283 wg.Go(func() {
284 tagsBytes, err := tangled.GitTempListTags(ctx, xrpcc, "", 0, repo.RepoAt().String())
285 if err != nil {
286 errs = errors.Join(errs, fmt.Errorf("failed to call git.ListTags: %w", err))
287 return
288 }
289
290 if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
291 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListTags: %w", err))
292 }
293 })
294
295 // tree/files
296 wg.Go(func() {
297 resp, err := tangled.GitTempGetTree(ctx, xrpcc, "", ref, repo.RepoAt().String())
298 if err != nil {
299 errs = errors.Join(errs, fmt.Errorf("failed to call git.GetTree: %w", err))
300 return
301 }
302 treeResp = resp
303 })
304
305 // commits
306 wg.Go(func() {
307 logBytes, err := tangled.GitTempListCommits(ctx, xrpcc, "", 50, ref, repo.RepoAt().String())
308 if err != nil {
309 errs = errors.Join(errs, fmt.Errorf("failed to call git.ListCommits: %w", err))
310 return
311 }
312
313 if err := json.Unmarshal(logBytes, &logResp); err != nil {
314 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListCommits: %w", err))
315 }
316 })
317
318 wg.Wait()
319
320 if errs != nil {
321 return nil, errs
322 }
323
324 var files []types.NiceTree
325 if treeResp != nil && treeResp.Files != nil {
326 for _, file := range treeResp.Files {
327 niceFile := types.NiceTree{
328 Name: file.Name,
329 Mode: file.Mode,
330 Size: file.Size,
331 }
332
333 if file.Last_commit != nil {
334 when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
335 niceFile.LastCommit = &types.LastCommitInfo{
336 Hash: plumbing.NewHash(file.Last_commit.Hash),
337 Message: file.Last_commit.Message,
338 When: when,
339 }
340 }
341 files = append(files, niceFile)
342 }
343 }
344
345 if treeResp != nil && treeResp.Readme != nil {
346 readmeFileName = treeResp.Readme.Filename
347 readmeContent = treeResp.Readme.Contents
348 }
349
350 result := &types.RepoIndexResponse{
351 IsEmpty: false,
352 Ref: ref,
353 Readme: readmeContent,
354 ReadmeFileName: readmeFileName,
355 Commits: logResp.Commits,
356 Description: "",
357 Files: files,
358 Branches: branchesResp.Branches,
359 Tags: tagsResp.Tags,
360 TotalCommits: logResp.Total,
361 }
362
363 return result, nil
364}