Monorepo for Tangled
0

Configure Feed

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

spindle: create pipeline events from spindle

spindle will emit `sh.tangled.pipeline` event on:
- `sh.tangled.git.refUpdate` events from knot stream
- live create/update events of `sh.tangled.repo.pull` records

Signed-off-by: Seongmin Lee <git@boltless.me>

Seongmin Lee (Jun 8, 2026, 1:22 AM +0900) 330f60aa 2521ddff

+409 -84
+1
knotserver/internal.go
··· 259 259 } 260 260 261 261 for _, line := range lines { 262 + // TODO: pass pushOptions to refUpdate 262 263 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoDid) 263 264 if err != nil { 264 265 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
+14
spindle/db/events.go
··· 19 19 return eventstream.List(d, cursor, limit) 20 20 } 21 21 22 + func (d *DB) CreatePipelineEvent(rkey string, pipeline tangled.Pipeline, n *notifier.Notifier) error { 23 + eventJson, err := json.Marshal(pipeline) 24 + if err != nil { 25 + return err 26 + } 27 + event := eventstream.Event{ 28 + Rkey: rkey, 29 + Nsid: tangled.PipelineNSID, 30 + Created: time.Now().UnixNano(), 31 + EventJson: eventJson, 32 + } 33 + return d.insertEvent(event, n) 34 + } 35 + 22 36 func (d *DB) createStatusEvent( 23 37 workflowId models.WorkflowId, 24 38 statusKind models.StatusKind,
+2 -2
spindle/embedtap.go
··· 67 67 RepoFetchTimeout: 5 * time.Minute, 68 68 IdentityCacheSize: 50_000, 69 69 EventCacheSize: 10_000, 70 - SignalCollection: tangled.RepoNSID, 71 - CollectionFilters: []string{tangled.RepoNSID, tangled.RepoCollaboratorNSID}, 70 + SignalCollection: tangled.RepoPullNSID, // HACK: to ingest PRs from any users 71 + CollectionFilters: []string{tangled.RepoNSID, tangled.RepoCollaboratorNSID, tangled.RepoPullNSID}, 72 72 AdminPassword: cfg.Server.Tap.AdminPassword, 73 73 RetryTimeout: 60 * time.Second, 74 74 }
+207 -82
spindle/server.go
··· 4 4 "context" 5 5 _ "embed" 6 6 "encoding/json" 7 + "errors" 7 8 "fmt" 8 9 "log/slog" 9 10 "maps" ··· 12 13 "sync" 13 14 14 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 17 "github.com/go-chi/chi/v5" 18 + "github.com/go-git/go-git/v5/plumbing/object" 16 19 "github.com/hashicorp/go-version" 17 20 "tangled.org/core/api/tangled" 18 21 "tangled.org/core/eventconsumer" ··· 20 23 "tangled.org/core/eventstream" 21 24 "tangled.org/core/idresolver" 22 25 "tangled.org/core/jetstream" 26 + kgit "tangled.org/core/knotserver/git" 23 27 "tangled.org/core/log" 24 28 "tangled.org/core/notifier" 25 29 "tangled.org/core/rbac" ··· 32 36 "tangled.org/core/spindle/queue" 33 37 "tangled.org/core/spindle/secrets" 34 38 "tangled.org/core/spindle/xrpc" 39 + "tangled.org/core/tid" 40 + "tangled.org/core/workflow" 35 41 "tangled.org/core/xrpc/serviceauth" 36 42 ) 37 43 ··· 186 192 return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 187 193 } 188 194 189 - // for each incoming sh.tangled.pipeline, we execute 190 - // spindle.processPipeline, which in turn enqueues the pipeline 191 - // job in the above registered queue. 195 + // spindle listen to knot stream for sh.tangled.git.refUpdate 196 + // which will sync the local workflow files in spindle and enqueues the 197 + // pipeline job for on-push workflows 192 198 ccfg := eventconsumer.NewConsumerConfig() 193 199 ccfg.Logger = log.SubLogger(logger, "eventconsumer") 194 - ccfg.ProcessFunc = spindle.processPipeline 200 + ccfg.ProcessFunc = spindle.processKnotStream 195 201 ccfg.CursorStore = cursorStore 196 202 knownKnots, err := d.Knots() 197 203 if err != nil { ··· 381 387 return x.Router() 382 388 } 383 389 384 - func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventstream.Event) error { 390 + func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventstream.Event) error { 385 391 l := log.FromContext(ctx).With("handler", "processKnotStream") 386 392 l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 387 393 if msg.Nsid == tangled.PipelineNSID { ··· 415 421 Rkey: msg.Rkey, 416 422 } 417 423 418 - workflows := make(map[models.Engine][]models.Workflow) 424 + err = s.processPipeline(ctx, repoDid, tpl, pipelineId) 425 + if err != nil { 426 + return err 427 + } 428 + } else if msg.Nsid == tangled.GitRefUpdateNSID { 429 + event := tangled.GitRefUpdate{} 430 + if err := json.Unmarshal(msg.EventJson, &event); err != nil { 431 + l.Error("error unmarshalling", "err", err) 432 + return err 433 + } 434 + l = l.With("repo", event.Repo, "ref", event.Ref, "newSha", event.NewSha) 435 + l.Debug("debug") 436 + 437 + repoDid := syntax.DID(event.Repo) 438 + repo, err := s.db.GetRepoByDid(repoDid) 439 + if err != nil { 440 + return fmt.Errorf("unknown repoDid %s: %w", repoDid, err) 441 + } 442 + 443 + // NOTE: we are blindly trusting the knot that it will return only repos it own 444 + repoCloneUri := s.newRepoCloneUrl(src.Key(), repoDid) 445 + repoPath := s.newRepoPath(repoDid) 446 + if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil { 447 + return fmt.Errorf("sync git repo: %w", err) 448 + } 449 + l.Info("synced git repo") 419 450 420 - // Build pipeline environment variables once for all workflows 421 - pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 451 + scheme := "https" 452 + if s.cfg.Server.Dev { 453 + scheme = "http" 454 + } 455 + client := &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, repo.Knot)} 422 456 423 - for _, w := range tpl.Workflows { 424 - if w != nil { 425 - if _, ok := s.engs[w.Engine]; !ok { 426 - err = s.db.StatusFailed(models.WorkflowId{ 427 - PipelineId: pipelineId, 428 - Name: w.Name, 429 - }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 430 - if err != nil { 431 - return fmt.Errorf("db.StatusFailed: %w", err) 432 - } 457 + // fetch current default branch 458 + defaultBranch, _ := func(repo syntax.DID) (string, error) { 459 + defaultBranchOut, err := tangled.RepoGetDefaultBranch(ctx, client, repo.String()) 460 + if err != nil { 461 + return "", err 462 + } 463 + return defaultBranchOut.Name, nil 464 + }(repoDid) 433 465 434 - continue 435 - } 466 + compiler := workflow.Compiler{ 467 + Trigger: tangled.Pipeline_TriggerMetadata{ 468 + Kind: string(workflow.TriggerKindPush), 469 + Push: &tangled.Pipeline_PushTriggerData{ 470 + Ref: event.Ref, 471 + OldSha: event.OldSha, 472 + NewSha: event.NewSha, 473 + }, 474 + Repo: &tangled.Pipeline_TriggerRepo{ 475 + Did: repo.Owner.String(), 476 + Knot: repo.Knot, 477 + Repo: (*string)(&repo.Rkey), 478 + RepoDid: (*string)(&repoDid), 479 + DefaultBranch: defaultBranch, 480 + }, 481 + }, 482 + } 436 483 437 - eng := s.engs[w.Engine] 484 + // load workflow definitions from rev (without spindle context) 485 + rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, event.NewSha) 486 + if err != nil { 487 + return fmt.Errorf("loading pipeline: %w", err) 488 + } 489 + if len(rawPipeline) == 0 { 490 + l.Info("no workflow definition find for the repo. skipping the event") 491 + return nil 492 + } 493 + tpl := compiler.Compile(compiler.Parse(rawPipeline)) 494 + // TODO: pass compile error to workflow log 495 + for _, w := range compiler.Diagnostics.Errors { 496 + l.Error(w.String()) 497 + } 498 + for _, w := range compiler.Diagnostics.Warnings { 499 + l.Warn(w.String()) 500 + } 501 + if len(tpl.Workflows) == 0 { 502 + l.Info("no workflow matching trigger 'push'. skipping the event") 503 + return nil 504 + } 438 505 439 - if _, ok := workflows[eng]; !ok { 440 - workflows[eng] = []models.Workflow{} 441 - } 506 + pipelineId := models.PipelineId{ 507 + Knot: tpl.TriggerMetadata.Repo.Knot, 508 + Rkey: tid.TID(), 509 + } 510 + if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 511 + l.Error("failed to create pipeline event", "err", err) 512 + return nil 513 + } 514 + err = s.processPipeline(ctx, repoDid, tpl, pipelineId) 515 + if err != nil { 516 + return err 517 + } 518 + } 442 519 443 - ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 444 - if err != nil { 445 - err = s.db.StatusFailed(models.WorkflowId{ 446 - PipelineId: pipelineId, 447 - Name: w.Name, 448 - }, fmt.Sprintf("init workflow: %s", err), -1, s.n) 449 - if err != nil { 450 - return fmt.Errorf("db.StatusFailed: %w", err) 451 - } 520 + return nil 521 + } 452 522 453 - continue 454 - } 523 + func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) { 524 + if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil { 525 + return nil, fmt.Errorf("syncing git repo: %w", err) 526 + } 527 + gr, err := kgit.Open(repoPath, rev) 528 + if err != nil { 529 + return nil, fmt.Errorf("opening git repo: %w", err) 530 + } 455 531 456 - // inject TANGLED_* env vars after InitWorkflow 457 - // This prevents user-defined env vars from overriding them 458 - if ewf.Environment == nil { 459 - ewf.Environment = make(map[string]string) 460 - } 461 - maps.Copy(ewf.Environment, pipelineEnv) 532 + workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 533 + if errors.Is(err, object.ErrDirectoryNotFound) { 534 + // return empty RawPipeline when directory doesn't exist 535 + return nil, nil 536 + } else if err != nil { 537 + return nil, fmt.Errorf("loading file tree: %w", err) 538 + } 462 539 463 - workflows[eng] = append(workflows[eng], *ewf) 540 + var rawPipeline workflow.RawPipeline 541 + for _, e := range workflowDir { 542 + if !e.IsFile() { 543 + continue 544 + } 464 545 465 - err = s.db.StatusPending(models.WorkflowId{ 466 - PipelineId: pipelineId, 467 - Name: w.Name, 468 - }, s.n) 469 - if err != nil { 470 - return fmt.Errorf("db.StatusPending: %w", err) 471 - } 472 - } 546 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 547 + contents, err := gr.RawContent(fpath) 548 + if err != nil { 549 + return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err) 473 550 } 474 551 475 - ok := s.jq.Enqueue(queue.Job{ 476 - Run: func() error { 477 - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.workflowSem, ctx, &models.Pipeline{ 478 - RepoDid: repoDid, 479 - Workflows: workflows, 480 - }, pipelineId) 481 - return nil 482 - }, 483 - OnFail: func(jobError error) { 484 - s.l.Error("pipeline run failed", "error", jobError) 485 - }, 552 + rawPipeline = append(rawPipeline, workflow.RawWorkflow{ 553 + Name: e.Name, 554 + Contents: contents, 486 555 }) 487 - if ok { 488 - s.l.Info("pipeline enqueued successfully", "id", msg.Rkey) 489 - } else { 490 - s.l.Error("failed to enqueue pipeline: queue is full") 556 + } 557 + 558 + return rawPipeline, nil 559 + } 560 + 561 + func (s *Spindle) processPipeline(ctx context.Context, repoDid syntax.DID, tpl tangled.Pipeline, pipelineId models.PipelineId) error { 562 + // Build pipeline environment variables once for all workflows 563 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 564 + 565 + // filter & init workflows 566 + workflows := make(map[models.Engine][]models.Workflow) 567 + for _, w := range tpl.Workflows { 568 + if w == nil { 569 + continue 491 570 } 492 - } else if msg.Nsid == tangled.GitRefUpdateNSID { 493 - event := tangled.GitRefUpdate{} 494 - if err := json.Unmarshal(msg.EventJson, &event); err != nil { 495 - l.Error("error unmarshalling", "err", err) 496 - return err 571 + if _, ok := s.engs[w.Engine]; !ok { 572 + err := s.db.StatusFailed(models.WorkflowId{ 573 + PipelineId: pipelineId, 574 + Name: w.Name, 575 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 576 + if err != nil { 577 + return fmt.Errorf("db.StatusFailed: %w", err) 578 + } 579 + 580 + continue 497 581 } 498 - l = l.With("repo", event.Repo, "ref", event.Ref, "newSha", event.NewSha) 499 - l.Debug("debug") 500 582 501 - repoDid := syntax.DID(event.Repo) 502 - if _, err := s.db.GetRepoByDid(repoDid); err != nil { 503 - return fmt.Errorf("unknown repoDid %s: %w", repoDid, err) 583 + eng := s.engs[w.Engine] 584 + 585 + if _, ok := workflows[eng]; !ok { 586 + workflows[eng] = []models.Workflow{} 504 587 } 505 588 506 - // NOTE: we are blindly trusting the knot that it will return only repos it own 507 - repoCloneUri := s.newRepoCloneUrl(src.Key(), syntax.DID(event.Repo)) 508 - repoPath := s.newRepoPath(syntax.DID(event.Repo)) 509 - if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil { 510 - return fmt.Errorf("sync git repo: %w", err) 589 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 590 + if err != nil { 591 + err = s.db.StatusFailed(models.WorkflowId{ 592 + PipelineId: pipelineId, 593 + Name: w.Name, 594 + }, fmt.Sprintf("init workflow: %s", err), -1, s.n) 595 + if err != nil { 596 + return fmt.Errorf("db.StatusFailed: %w", err) 597 + } 598 + 599 + continue 511 600 } 512 - l.Info("synced git repo") 513 601 514 - // TODO: plan the pipeline 602 + // inject TANGLED_* env vars after InitWorkflow 603 + // This prevents user-defined env vars from overriding them 604 + if ewf.Environment == nil { 605 + ewf.Environment = make(map[string]string) 606 + } 607 + maps.Copy(ewf.Environment, pipelineEnv) 608 + 609 + workflows[eng] = append(workflows[eng], *ewf) 515 610 } 516 611 612 + // enqueue pipeline 613 + ok := s.jq.Enqueue(queue.Job{ 614 + Run: func() error { 615 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.workflowSem, ctx, &models.Pipeline{ 616 + RepoDid: repoDid, 617 + Workflows: workflows, 618 + }, pipelineId) 619 + return nil 620 + }, 621 + OnFail: func(jobError error) { 622 + s.l.Error("pipeline run failed", "error", jobError) 623 + }, 624 + }) 625 + if !ok { 626 + return fmt.Errorf("failed to enqueue pipeline: queue is full") 627 + } 628 + s.l.Info("pipeline enqueued successfully", "id", pipelineId) 629 + 630 + // after successful enqueue, emit StatusPending for all workflows 631 + for _, ewfs := range workflows { 632 + for _, ewf := range ewfs { 633 + err := s.db.StatusPending(models.WorkflowId{ 634 + PipelineId: pipelineId, 635 + Name: ewf.Name, 636 + }, s.n) 637 + if err != nil { 638 + return fmt.Errorf("db.StatusPending: %w", err) 639 + } 640 + } 641 + } 517 642 return nil 518 643 } 519 644
+185
spindle/tapclient.go
··· 6 6 "encoding/json" 7 7 "errors" 8 8 "fmt" 9 + "io" 9 10 "log/slog" 11 + "net/http" 12 + "net/url" 10 13 "sync" 11 14 "time" 12 15 13 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 14 18 "tangled.org/core/api/tangled" 19 + avmodels "tangled.org/core/appview/models" 15 20 "tangled.org/core/eventconsumer" 16 21 "tangled.org/core/log" 17 22 "tangled.org/core/rbac" 18 23 "tangled.org/core/spindle/db" 19 24 "tangled.org/core/spindle/git" 25 + "tangled.org/core/spindle/models" 20 26 "tangled.org/core/tapc" 27 + "tangled.org/core/tid" 28 + "tangled.org/core/workflow" 21 29 ) 22 30 23 31 const ( ··· 75 83 return t.processRepo(ctx, evt.Record) 76 84 case tangled.RepoCollaboratorNSID: 77 85 return t.processCollaborator(ctx, evt.Record) 86 + case tangled.RepoPullNSID: 87 + return t.processPull(ctx, evt.Record) 78 88 } 79 89 return nil 80 90 } ··· 306 316 return nil 307 317 } 308 318 319 + func (t *Tap) processPull(ctx context.Context, evt *tapc.RecordEventData) error { 320 + l := t.logger.With("collection", evt.Collection, "did", evt.Did, "rkey", evt.Rkey) 321 + 322 + // only listen to live events 323 + if !evt.Live { 324 + l.Info("skipping backfill event", "event", evt.AtUri()) 325 + return nil 326 + } 327 + 328 + switch evt.Action { 329 + case tapc.RecordCreateAction, tapc.RecordUpdateAction: 330 + record := tangled.RepoPull{} 331 + if err := json.Unmarshal(evt.Record, &record); err != nil { 332 + l.Error("invalid record", "err", err) 333 + return fmt.Errorf("parsing record: %w", err) 334 + } 335 + 336 + // ignore legacy records 337 + if record.Target == nil { 338 + l.Info("ignoring pull record: target repo is nil") 339 + return nil 340 + } 341 + 342 + // ignore patch-based and fork-based PRs 343 + if record.Source == nil || record.Source.Repo != nil { 344 + l.Info("ignoring pull record: not a branch-based pull request") 345 + return nil 346 + } 347 + 348 + // skip if target repo is unknown 349 + repo, err := t.spindle.db.GetRepoByDid(syntax.DID(record.Target.Repo)) 350 + if err != nil { 351 + l.Warn("target repo is not ingested yet", "repo", record.Target.Repo, "err", err) 352 + return fmt.Errorf("target repo is unknown") 353 + } 354 + 355 + // only accept branch-based PR (excluding patch-based and fork-based) 356 + if record.Source == nil || record.Source.Repo != nil { 357 + l.Warn("skipping non-branch-based PR") 358 + return nil 359 + } 360 + 361 + latestSubmission, err := t.fetchLatestSubmission(ctx, evt.Did.String(), evt.Rkey.String(), &record) 362 + if err != nil { 363 + return err 364 + } 365 + sourceSha := latestSubmission.SourceRev 366 + 367 + scheme := "https" 368 + if t.spindle.cfg.Server.Dev { 369 + scheme = "http" 370 + } 371 + client := &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, repo.Knot)} 372 + 373 + // fetch current default branch 374 + defaultBranch, _ := func(repo syntax.DID) (string, error) { 375 + defaultBranchOut, err := tangled.RepoGetDefaultBranch(ctx, client, repo.String()) 376 + if err != nil { 377 + return "", err 378 + } 379 + return defaultBranchOut.Name, nil 380 + }(repo.RepoDid) 381 + 382 + compiler := workflow.Compiler{ 383 + Trigger: tangled.Pipeline_TriggerMetadata{ 384 + Kind: string(workflow.TriggerKindPullRequest), 385 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 386 + Action: "create", 387 + SourceBranch: record.Source.Branch, 388 + SourceSha: sourceSha, 389 + TargetBranch: record.Target.Branch, 390 + }, 391 + Repo: &tangled.Pipeline_TriggerRepo{ 392 + Did: repo.Owner.String(), 393 + Knot: repo.Knot, 394 + Repo: (*string)(&repo.Rkey), 395 + RepoDid: (*string)(&repo.RepoDid), 396 + DefaultBranch: defaultBranch, 397 + }, 398 + }, 399 + } 400 + 401 + repoUri := t.spindle.newRepoCloneUrl(repo.Knot, repo.RepoDid) 402 + repoPath := t.spindle.newRepoPath(repo.RepoDid) 403 + 404 + // load workflow definitions from rev (without spindle context) 405 + rawPipeline, err := t.spindle.loadPipeline(ctx, repoUri, repoPath, sourceSha) 406 + if err != nil { 407 + // don't retry 408 + l.Error("failed loading pipeline", "err", err) 409 + return nil 410 + } 411 + if len(rawPipeline) == 0 { 412 + l.Info("no workflow definition find for the repo. skipping the event") 413 + return nil 414 + } 415 + tpl := compiler.Compile(compiler.Parse(rawPipeline)) 416 + // TODO: pass compile error to workflow log 417 + for _, w := range compiler.Diagnostics.Errors { 418 + l.Error(w.String()) 419 + } 420 + for _, w := range compiler.Diagnostics.Warnings { 421 + l.Warn(w.String()) 422 + } 423 + if len(tpl.Workflows) == 0 { 424 + l.Info("no workflow matching trigger 'pull_request'. skipping the event") 425 + return nil 426 + } 427 + 428 + pipelineId := models.PipelineId{ 429 + Knot: tpl.TriggerMetadata.Repo.Knot, 430 + Rkey: tid.TID(), 431 + } 432 + if err := t.spindle.db.CreatePipelineEvent(pipelineId.Rkey, tpl, t.spindle.n); err != nil { 433 + l.Error("failed to create pipeline event", "err", err) 434 + return nil 435 + } 436 + err = t.spindle.processPipeline(ctx, repo.RepoDid, tpl, pipelineId) 437 + if err != nil { 438 + // don't retry 439 + l.Error("failed processing pipeline", "err", err) 440 + return nil 441 + } 442 + case tapc.RecordDeleteAction: 443 + // no-op 444 + } 445 + return nil 446 + } 447 + 309 448 func (t *Tap) bufferCollab(repoDid syntax.DID, evt *tapc.RecordEventData) { 310 449 t.pendingMu.Lock() 311 450 defer t.pendingMu.Unlock() ··· 373 512 t.logger.Warn("expired buffered collaborator events without matching repo arrival", "count", expired, "ttl", pendingCollabTTL) 374 513 } 375 514 } 515 + 516 + func (t *Tap) fetchLatestSubmission(ctx context.Context, did, rkey string, record *tangled.RepoPull) (*avmodels.PullSubmission, error) { 517 + // resolve the PR owner's identity to fetch the blob from their PDS 518 + prOwnerIdent, err := t.spindle.res.ResolveIdent(ctx, did) 519 + if err != nil || prOwnerIdent.Handle.IsInvalidHandle() { 520 + return nil, fmt.Errorf("failed to resolve PR owner handle: %w", err) 521 + } 522 + 523 + if len(record.Rounds) == 0 { 524 + return nil, fmt.Errorf("failed to fetch latest submission, no rounds in record") 525 + } 526 + 527 + roundNumber := len(record.Rounds) - 1 528 + round := record.Rounds[roundNumber] 529 + 530 + // fetch the blob from the PR owner's PDS 531 + prOwnerPds := prOwnerIdent.PDSEndpoint() 532 + blobUrl, err := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", prOwnerPds)) 533 + if err != nil { 534 + return nil, fmt.Errorf("failed to construct blob URL: %w", err) 535 + } 536 + q := blobUrl.Query() 537 + q.Set("cid", round.PatchBlob.Ref.String()) 538 + q.Set("did", did) 539 + blobUrl.RawQuery = q.Encode() 540 + 541 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, blobUrl.String(), nil) 542 + if err != nil { 543 + return nil, fmt.Errorf("failed to create blob request: %w", err) 544 + } 545 + req.Header.Set("Content-Type", "application/json") 546 + 547 + blobResp, err := http.DefaultClient.Do(req) 548 + if err != nil { 549 + return nil, fmt.Errorf("failed to fetch blob: %w", err) 550 + } 551 + defer blobResp.Body.Close() 552 + 553 + blob := io.ReadCloser(blobResp.Body) 554 + latestSubmission, err := avmodels.PullSubmissionFromRecord(did, rkey, roundNumber, round, &blob) 555 + if err != nil { 556 + return nil, fmt.Errorf("failed to parse submission: %w", err) 557 + } 558 + 559 + return latestSubmission, nil 560 + }