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