This repository has no description
0

Configure Feed

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

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