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