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