A vibe coded tangled fork which supports pijul.
at master 392 lines 10 kB view raw
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