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