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