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