This repository has no description
1package repo
2
3import (
4 "errors"
5 "fmt"
6 "net/http"
7 "net/url"
8 "slices"
9 "sort"
10 "strings"
11 "sync"
12 "time"
13
14 "context"
15 "encoding/json"
16
17 "github.com/bluesky-social/indigo/atproto/syntax"
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/pages/markup"
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 languageInfo, err = rp.getLanguageInfo(r.Context(), syntax.DID(f.RepoDid), result.Ref)
118 if err != nil {
119 l.Warn("failed to compute language percentages", "err", err)
120 // non-fatal
121 }
122 }
123
124 var shas []string
125 for _, c := range commitsTrunc {
126 shas = append(shas, c.Hash.String())
127 }
128 pipelines, err := getPipelineStatuses(rp.db, f, shas)
129 if err != nil {
130 l.Error("failed to fetch pipeline statuses", "err", err)
131 // non-fatal
132 }
133
134 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
135 LoggedInUser: user,
136 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
137 TagMap: tagMap,
138 RepoIndexResponse: *result,
139 CommitsTrunc: commitsTrunc,
140 TagsTrunc: tagsTrunc,
141 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
142 BranchesTrunc: branchesTrunc,
143 EmailToDid: emailToDidMap,
144 VerifiedCommits: vc,
145 Languages: languageInfo,
146 Pipelines: pipelines,
147 })
148}
149
150func (rp *Repo) getLanguageInfo(
151 ctx context.Context,
152 repoId syntax.DID,
153 ref string,
154) ([]types.RepoLanguageDetails, error) {
155 var langs []models.RepoLanguage
156 // non-fatal, fetch langs from knotmirror via XRPC
157 xrpcc := &indigoxrpc.Client{
158 Host: rp.config.KnotMirror.Url,
159 Client: http.DefaultClient,
160 }
161 ls, err := tangled.GitTempListLanguages(ctx, xrpcc, ref, repoId.String())
162 if err != nil {
163 return nil, fmt.Errorf("calling knotmirror git.listLanguages: %w", err)
164 }
165
166 if ls == nil || ls.Languages == nil {
167 return nil, nil
168 }
169
170 var total int64
171 for _, lang := range ls.Languages {
172 total += lang.Size
173 langs = append(langs, models.RepoLanguage{
174 RepoDid: repoId,
175 Ref: ref,
176 Language: lang.Name,
177 Bytes: lang.Size,
178 })
179 }
180
181 var languageStats []types.RepoLanguageDetails
182 for _, l := range langs {
183 percentage := float32(l.Bytes) / float32(total) * 100
184 color := enry.GetColor(l.Language)
185 languageStats = append(languageStats, types.RepoLanguageDetails{
186 Name: l.Language,
187 Percentage: percentage,
188 Color: color,
189 })
190 }
191
192 sort.Slice(languageStats, func(i, j int) bool {
193 if languageStats[i].Name == enry.OtherLanguage {
194 return false
195 }
196 if languageStats[j].Name == enry.OtherLanguage {
197 return true
198 }
199 if languageStats[i].Percentage != languageStats[j].Percentage {
200 return languageStats[i].Percentage > languageStats[j].Percentage
201 }
202 return languageStats[i].Name < languageStats[j].Name
203 })
204
205 return languageStats, nil
206}
207
208// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel
209func (rp *Repo) buildIndexResponse(ctx context.Context, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) {
210 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url}
211
212 branchesBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid)
213 if err != nil {
214 return nil, fmt.Errorf("calling knotmirror git.listBranches: %w", err)
215 }
216
217 var branchesResp types.RepoBranchesResponse
218 if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
219 return nil, fmt.Errorf("failed to unmarshal branches response: %w", err)
220 }
221
222 // if no ref specified, use default branch or first available
223 if ref == "" {
224 for _, branch := range branchesResp.Branches {
225 if branch.IsDefault {
226 ref = branch.Name
227 break
228 }
229 }
230 }
231
232 // if ref is still empty, this means the default branch is not set
233 if ref == "" {
234 return &types.RepoIndexResponse{
235 IsEmpty: true,
236 Branches: branchesResp.Branches,
237 }, nil
238 }
239
240 // now run the remaining queries in parallel
241 var wg sync.WaitGroup
242 var errs error
243
244 var (
245 tagsResp types.RepoTagsResponse
246 treeResp *tangled.GitTempGetTree_Output
247 logResp types.RepoLogResponse
248 readmeContent string
249 readmeFileName string
250 )
251
252 // tags
253 wg.Go(func() {
254 tagsBytes, err := tangled.GitTempListTags(ctx, xrpcc, "", 0, repo.RepoDid)
255 if err != nil {
256 errs = errors.Join(errs, fmt.Errorf("failed to call git.ListTags: %w", err))
257 return
258 }
259
260 if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
261 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListTags: %w", err))
262 }
263 })
264
265 // tree/files
266 wg.Go(func() {
267 resp, err := tangled.GitTempGetTree(ctx, xrpcc, "", ref, repo.RepoDid)
268 if err != nil {
269 errs = errors.Join(errs, fmt.Errorf("failed to call git.GetTree: %w", err))
270 return
271 }
272 treeResp = resp
273
274 for _, file := range resp.Files {
275 if markup.IsReadmeFile(file.Name) {
276 readmeFileName = file.Name
277 break
278 }
279 }
280
281 if readmeFileName != "" {
282 bytes, err := tangled.GitTempGetBlob(ctx, xrpcc, readmeFileName, ref, repo.RepoDid)
283 if err != nil {
284 errs = errors.Join(errs, fmt.Errorf("failed to call git.getBlob: %w", err))
285 return
286 }
287 readmeContent = string(bytes)
288 }
289 })
290
291 // commits
292 wg.Go(func() {
293 logBytes, err := tangled.GitTempListCommits(ctx, xrpcc, "", 50, ref, repo.RepoDid)
294 if err != nil {
295 errs = errors.Join(errs, fmt.Errorf("failed to call git.ListCommits: %w", err))
296 return
297 }
298
299 if err := json.Unmarshal(logBytes, &logResp); err != nil {
300 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListCommits: %w", err))
301 }
302 })
303
304 wg.Wait()
305
306 if errs != nil {
307 return nil, errs
308 }
309
310 var files []types.NiceTree
311 if treeResp != nil && treeResp.Files != nil {
312 for _, file := range treeResp.Files {
313 niceFile := types.NiceTree{
314 Name: file.Name,
315 Mode: file.Mode,
316 Size: file.Size,
317 }
318
319 if file.Last_commit != nil {
320 when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
321 niceFile.LastCommit = &types.LastCommitInfo{
322 Hash: plumbing.NewHash(file.Last_commit.Hash),
323 Message: file.Last_commit.Message,
324 When: when,
325 }
326 }
327 files = append(files, niceFile)
328 }
329 }
330
331 result := &types.RepoIndexResponse{
332 IsEmpty: false,
333 Ref: ref,
334 Readme: readmeContent,
335 ReadmeFileName: readmeFileName,
336 Commits: logResp.Commits,
337 Description: "",
338 Files: files,
339 Branches: branchesResp.Branches,
340 Tags: tagsResp.Tags,
341 TotalCommits: logResp.Total,
342 }
343
344 return result, nil
345}