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