Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / repo / index.go
11 kB 419 lines
1package repo 2 3import ( 4 "errors" 5 "fmt" 6 "maps" 7 "net/http" 8 "net/url" 9 "slices" 10 "sort" 11 "strings" 12 "sync" 13 "time" 14 15 "context" 16 "encoding/json" 17 18 "github.com/bluesky-social/indigo/atproto/syntax" 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/pages/markup" 26 "tangled.org/core/types" 27 28 "github.com/go-chi/chi/v5" 29 "github.com/go-enry/go-enry/v2" 30 "github.com/samber/lo" 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 user := rp.oauth.GetMultiAccountUser(r) 46 47 if user != nil { 48 userDid := user.Did 49 repoDid := f.RepoDid 50 go func() { 51 if err := db.UpsertRecentLink(rp.db, userDid, models.RecentLinkTypeRepo, repoDid); err != nil { 52 l.Error("failed to upsert recent link", "err", err) 53 } 54 }() 55 } 56 57 // Build index response from multiple XRPC calls 58 result, err := rp.buildIndexResponse(r.Context(), f, ref) 59 if err != nil { 60 l.Error("failed to build index response", "err", err) 61 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 62 BaseParams: pages.BaseParamsFromContext(r.Context()), 63 KnotUnreachable: true, 64 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 65 }) 66 return 67 } 68 69 tagMap := make(map[string][]string) 70 for _, tag := range result.Tags { 71 hash := tag.Hash 72 if tag.Tag != nil { 73 hash = tag.Tag.Target.String() 74 } 75 tagMap[hash] = append(tagMap[hash], tag.Name) 76 } 77 78 for _, branch := range result.Branches { 79 hash := branch.Hash 80 tagMap[hash] = append(tagMap[hash], branch.Name) 81 } 82 83 sortFiles(result.Files) 84 85 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 86 if a.Name == result.Ref { 87 return -1 88 } 89 if a.IsDefault { 90 return -1 91 } 92 if b.IsDefault { 93 return 1 94 } 95 if a.Commit != nil && b.Commit != nil { 96 if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 97 return 1 98 } else { 99 return -1 100 } 101 } 102 return strings.Compare(a.Name, b.Name) * -1 103 }) 104 105 commitCount := len(result.Commits) 106 branchCount := len(result.Branches) 107 tagCount := len(result.Tags) 108 fileCount := len(result.Files) 109 110 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) 111 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] 112 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] 113 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))] 114 115 var shas []string 116 for _, c := range commitsTrunc { 117 shas = append(shas, c.Hash.String()) 118 } 119 type pipelineResult struct { 120 pipelines map[string]types.Pipeline 121 err error 122 } 123 pipelineCh := make(chan pipelineResult, 1) 124 go func() { 125 p, err := getPipelineStatuses(r.Context(), f, shas) 126 pipelineCh <- pipelineResult{p, err} 127 }() 128 129 emails := uniqueEmails(commitsTrunc) 130 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 131 if err != nil { 132 l.Error("failed to get email to did map", "err", err) 133 } 134 135 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, commitsTrunc) 136 if err != nil { 137 l.Error("failed to GetVerifiedObjectCommits", "err", err) 138 } 139 140 var languageInfo []types.RepoLanguageDetails 141 if !result.IsEmpty { 142 langs, err := rp.getLanguageInfo(r.Context(), syntax.DID(f.RepoDid), result.Ref) 143 if err != nil { 144 l.Warn("failed to compute language percentages", "err", err) 145 // non-fatal 146 } else if ref == "" { // when request didn't specified ref, we are fetching default branch. 147 if err := func(repo syntax.DID, ref string, langs []*tangled.GitTempListLanguages_Language) error { 148 current := lo.SliceToMap(langs, func(lang *tangled.GitTempListLanguages_Language) (string, int64) { 149 return lang.Name, lang.Size 150 }) 151 152 existing, err := db.GetRepoLanguages(rp.db, repo, ref) 153 if err != nil { 154 return err 155 } 156 if maps.Equal(current, existing) { 157 return nil 158 } 159 160 tx, err := rp.db.Begin() 161 if err != nil { 162 return err 163 } 164 defer tx.Rollback() 165 166 mlangs := lo.Map(langs, func(lang *tangled.GitTempListLanguages_Language, _ int) models.RepoLanguage { 167 return models.RepoLanguage{ 168 RepoDid: repo, 169 Ref: ref, 170 IsDefaultRef: true, 171 Language: lang.Name, 172 Bytes: lang.Size, 173 } 174 }) 175 176 if err := db.UpdateRepoLanguages(tx, repo, ref, mlangs); err != nil { 177 return err 178 } 179 180 return tx.Commit() 181 }(syntax.DID(f.RepoDid), result.Ref, langs); err != nil { 182 l.Error("failed to populate appview repo languages index", "err", err) 183 // non-fatal 184 } 185 languageInfo = makeLanguageStats(langs) 186 } 187 } 188 189 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 190 BaseParams: pages.BaseParamsFromContext(r.Context()), 191 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 192 TagMap: tagMap, 193 RepoIndexResponse: *result, 194 CommitsTrunc: commitsTrunc, 195 TagsTrunc: tagsTrunc, 196 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 197 BranchesTrunc: branchesTrunc, 198 EmailToDid: emailToDidMap, 199 VerifiedCommits: vc, 200 Languages: languageInfo, 201 }) 202} 203 204func (rp *Repo) PipelineStatuses(w http.ResponseWriter, r *http.Request) { 205 l := rp.logger.With("handler", "PipelineStatuses") 206 207 f, err := rp.repoResolver.Resolve(r) 208 if err != nil { 209 l.Error("failed to resolve repo", "err", err) 210 return 211 } 212 213 user := rp.oauth.GetMultiAccountUser(r) 214 shas := r.URL.Query()["sha"] 215 216 pipelines, err := getPipelineStatuses(r.Context(), f, shas) 217 if err != nil { 218 l.Error("failed to fetch pipeline statuses", "err", err) 219 return 220 } 221 222 rp.pages.PipelineStatusesFragment(w, pages.PipelineStatusesParams{ 223 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 224 Pipelines: pipelines, 225 }) 226} 227 228func (rp *Repo) getLanguageInfo( 229 ctx context.Context, 230 repoId syntax.DID, 231 ref string, 232) ([]*tangled.GitTempListLanguages_Language, error) { 233 // non-fatal, fetch langs from knotmirror via XRPC 234 xrpcc := rp.knotMirrorXRPCClient() 235 out, err := tangled.GitTempListLanguages(ctx, xrpcc, ref, repoId.String()) 236 if err != nil { 237 return nil, fmt.Errorf("calling knotmirror git.listLanguages: %w", err) 238 } 239 240 if out == nil || out.Languages == nil { 241 return nil, nil 242 } 243 244 return out.Languages, nil 245} 246 247func makeLanguageStats(langs []*tangled.GitTempListLanguages_Language) []types.RepoLanguageDetails { 248 if len(langs) == 0 { 249 return nil 250 } 251 var total int64 252 for _, lang := range langs { 253 total += lang.Size 254 } 255 256 var languageStats []types.RepoLanguageDetails 257 for _, l := range langs { 258 languageStats = append(languageStats, types.RepoLanguageDetails{ 259 Name: l.Name, 260 Color: enry.GetColor(l.Name), 261 Percentage: float32(l.Size) / float32(total) * 100, 262 }) 263 } 264 265 sort.Slice(languageStats, func(i, j int) bool { 266 if languageStats[i].Name == enry.OtherLanguage { 267 return false 268 } 269 if languageStats[j].Name == enry.OtherLanguage { 270 return true 271 } 272 if languageStats[i].Percentage != languageStats[j].Percentage { 273 return languageStats[i].Percentage > languageStats[j].Percentage 274 } 275 return languageStats[i].Name < languageStats[j].Name 276 }) 277 return languageStats 278} 279 280// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 281func (rp *Repo) buildIndexResponse(ctx context.Context, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 282 xrpcc := rp.knotMirrorXRPCClient() 283 284 branchesBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid) 285 if err != nil { 286 return nil, fmt.Errorf("calling knotmirror git.listBranches: %w", err) 287 } 288 289 var branchesResp types.RepoBranchesResponse 290 if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil { 291 return nil, fmt.Errorf("failed to unmarshal branches response: %w", err) 292 } 293 294 // if no ref specified, use default branch or first available 295 if ref == "" { 296 for _, branch := range branchesResp.Branches { 297 if branch.IsDefault { 298 ref = branch.Name 299 break 300 } 301 } 302 } 303 304 // if ref is still empty, this means the default branch is not set 305 if ref == "" { 306 return &types.RepoIndexResponse{ 307 IsEmpty: true, 308 Branches: branchesResp.Branches, 309 TotalBranches: branchesResp.Total, 310 }, nil 311 } 312 313 // now run the remaining queries in parallel 314 var wg sync.WaitGroup 315 var errs error 316 317 var ( 318 tagsResp types.RepoTagsResponse 319 treeResp *tangled.GitTempGetTree_Output 320 logResp types.RepoLogResponse 321 readmeContent string 322 readmeFileName string 323 ) 324 325 // tags 326 wg.Go(func() { 327 tagsBytes, err := tangled.GitTempListTags(ctx, xrpcc, "", 0, repo.RepoDid) 328 if err != nil { 329 errs = errors.Join(errs, fmt.Errorf("failed to call git.ListTags: %w", err)) 330 return 331 } 332 333 if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 334 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListTags: %w", err)) 335 } 336 }) 337 338 // tree/files 339 wg.Go(func() { 340 resp, err := tangled.GitTempGetTree(ctx, xrpcc, "", ref, repo.RepoDid) 341 if err != nil { 342 errs = errors.Join(errs, fmt.Errorf("failed to call git.GetTree: %w", err)) 343 return 344 } 345 treeResp = resp 346 347 for _, file := range resp.Files { 348 if markup.IsReadmeFile(file.Name, file.Mode) { 349 readmeFileName = file.Name 350 break 351 } 352 } 353 354 if readmeFileName != "" { 355 bytes, err := tangled.GitTempGetBlob(ctx, xrpcc, readmeFileName, ref, repo.RepoDid) 356 if err != nil { 357 errs = errors.Join(errs, fmt.Errorf("failed to call git.getBlob: %w", err)) 358 return 359 } 360 readmeContent = string(bytes) 361 } 362 }) 363 364 // commits 365 wg.Go(func() { 366 logBytes, err := tangled.GitTempListCommits(ctx, xrpcc, "", 50, ref, repo.RepoDid) 367 if err != nil { 368 errs = errors.Join(errs, fmt.Errorf("failed to call git.ListCommits: %w", err)) 369 return 370 } 371 372 if err := json.Unmarshal(logBytes, &logResp); err != nil { 373 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListCommits: %w", err)) 374 } 375 }) 376 377 wg.Wait() 378 379 if errs != nil { 380 return nil, errs 381 } 382 383 var files []types.NiceTree 384 if treeResp != nil && treeResp.Files != nil { 385 for _, file := range treeResp.Files { 386 niceFile := types.NiceTree{ 387 Name: file.Name, 388 Mode: file.Mode, 389 Size: file.Size, 390 } 391 392 if file.Last_commit != nil { 393 when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 394 niceFile.LastCommit = &types.LastCommitInfo{ 395 Hash: plumbing.NewHash(file.Last_commit.Hash), 396 Message: file.Last_commit.Message, 397 When: when, 398 } 399 } 400 files = append(files, niceFile) 401 } 402 } 403 404 result := &types.RepoIndexResponse{ 405 IsEmpty: false, 406 Ref: ref, 407 Readme: readmeContent, 408 ReadmeFileName: readmeFileName, 409 Commits: logResp.Commits, 410 Files: files, 411 Branches: branchesResp.Branches, 412 TotalBranches: branchesResp.Total, 413 Tags: tagsResp.Tags, 414 TotalTags: tagsResp.Total, 415 TotalCommits: logResp.Total, 416 } 417 418 return result, nil 419}