package pulls import ( "context" "errors" "fmt" "io" "net/http" "strings" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" "tangled.org/core/workflow" ) func (s *Pulls) changedWorkflowFiles(ctx context.Context, baseRepo syntax.DID, base string, headRepo syntax.DID, head string) ([]string, error) { req := &gitmirrorv1.DiffRequest{ Head: &gitmirrorv1.RepoCommit{Repo: headRepo.String(), Commit: []byte(head)}, } if base != "" { req.Base = &gitmirrorv1.RepoCommit{Repo: baseRepo.String(), Commit: []byte(base)} } stream, err := s.gitmirror.Diff(ctx, req) if err != nil { return nil, fmt.Errorf("failed to diff: %w", err) } var changed []string for { fd, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { return nil, fmt.Errorf("failed to drain diff response: %w", err) } for _, name := range []string{fd.LhsSrc.Path, fd.RhsSrc.Path} { if name != "" && strings.HasPrefix(name, workflow.WorkflowDir+"/") { changed = append(changed, name) break } } } return changed, nil } // TriggerCi manually triggers a CI pipeline for a fork-based pull request. // authorized against and recorded under the target repo, but checked out // from the fork at the latest round's commit. func (s *Pulls) TriggerCi(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "TriggerCi") errorId := "pull-error" fail := func(msg string, err error) { if err != nil { l.Error(msg, "err", err) } else { l.Error(msg) } s.pages.Notice(w, errorId, msg) } f, err := s.repoResolver.Resolve(r) if err != nil { fail("failed to resolve repository", err) return } pull, ok := r.Context().Value("pull").(*models.Pull) if !ok { fail("failed to get pull", nil) return } l = l.With("pull_id", pull.PullId) if !pull.IsForkBased() { fail("this pull request is not fork-based", nil) return } if f.Spindle == "" { fail("this repository has no spindle configured", nil) return } latest := pull.LatestVersion() if latest.Base == "" || latest.Head == "" { fail("cannot trigger ci: this round has no commit to run", nil) return } changedFiles, err := func(ctx context.Context) ([]string, error) { base, err := s.resolveRev(ctx, pull.RepoDid, pull.TargetBranch) if err != nil { return nil, fmt.Errorf("failed to resolve target branch: %w", err) } return s.changedWorkflowFiles(ctx, pull.RepoDid, base, pull.SourceRepo, latest.Head) }(r.Context()) if err != nil { fail("failed to inspect the latest round's patch", err) return } if len(changedFiles) > 0 && r.URL.Query().Get("confirm") != "1" { fail(fmt.Sprintf("workflow files changed in this round (%s); review before running", strings.Join(changedFiles, ", ")), nil) return } forkRepo, err := db.GetRepoByDid(s.db, pull.SourceRepo.String()) if err != nil { fail("failed to resolve the fork this pull request comes from", err) return } spindleClient, err := s.oauth.SpindleServiceClient(r, f.Spindle, tangled.CiTriggerPipelineNSID) if err != nil { fail("failed to authorize with spindle", err) return } pullAt := pull.AtUri().String() sourceBranch := pull.SourceBranch targetBranch := pull.TargetBranch out, err := tangled.CiTriggerPipeline( r.Context(), spindleClient, &tangled.CiTriggerPipeline_Input{ Repo: f.RepoDid, Trigger: &tangled.CiTriggerPipeline_Input_Trigger{ CiTrigger_PullRequest: &tangled.CiTrigger_PullRequest{ Pull: &pullAt, SourceBranch: sourceBranch, SourceRepo: &forkRepo.RepoDid, SourceSha: latest.Head, TargetBranch: targetBranch, }, }, }, ) if err != nil { fail("spindle rejected the trigger", err) return } l.Info("triggered ci for fork-based pull", "pipeline", out.Pipeline) user := s.oauth.GetMultiAccountUser(r) repoInfo := s.repoResolver.GetRepoInfo(r, user) dest := fmt.Sprintf("/%s/pulls/%d/round/%d", repoInfo.FullName(), pull.PullId, pull.LatestVersionNumber()) if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", dest) w.WriteHeader(http.StatusOK) return } http.Redirect(w, r, dest, http.StatusSeeOther) }