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