package models import ( "bytes" "compress/gzip" "fmt" "io" "log" "slices" "strings" "time" "tangled.org/core/api/tangled" "tangled.org/core/appview/pages/markup/sanitizer" "tangled.org/core/patchutil" "tangled.org/core/types" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" ) type PullState int const ( PullClosed PullState = iota PullOpen PullMerged PullAbandoned ) func (p PullState) String() string { switch p { case PullOpen: return "open" case PullMerged: return "merged" case PullClosed: return "closed" case PullAbandoned: return "abandoned" default: return "closed" } } func (p PullState) IsOpen() bool { return p == PullOpen } func (p PullState) IsMerged() bool { return p == PullMerged } func (p PullState) IsClosed() bool { return p == PullClosed } func (p PullState) IsAbandoned() bool { return p == PullAbandoned } type Pull struct { // ids ID int PullId int // at ids RepoDid syntax.DID OwnerDid string Rkey string // content Title string Body string TargetBranch string State PullState Submissions []*PullSubmission Mentions []syntax.DID References []syntax.ATURI // stacking DependentOn *syntax.ATURI // meta Created time.Time PullSource *PullSource // optionally, populate this when querying for reverse mappings Labels LabelState Repo *Repo } // NOTE: This method does not include patch blob in returned atproto record func (p Pull) AsRecord() tangled.RepoPull { mentions := make([]string, len(p.Mentions)) for i, did := range p.Mentions { mentions[i] = string(did) } references := make([]string, len(p.References)) for i, uri := range p.References { references[i] = string(uri) } rounds := make([]*tangled.RepoPull_Round, len(p.Submissions)) for i, submission := range p.Submissions { rounds[i] = submission.AsRecord() } var dependentOn *string if p.DependentOn != nil { x := p.DependentOn.String() dependentOn = &x } return tangled.RepoPull{ Title: p.Title, Body: &p.Body, Mentions: mentions, References: references, CreatedAt: p.Created.Format(time.RFC3339), Target: &tangled.RepoPull_Target{ Repo: string(p.RepoDid), Branch: p.TargetBranch, }, Rounds: rounds, Source: p.PullSource.AsRecord(), DependentOn: dependentOn, } } func (pull *Pull) Validate() error { if len(pull.Submissions) == 0 { return fmt.Errorf("pull must have at least one submission") } latestSubmission := pull.LatestSubmission() if latestSubmission == nil { return fmt.Errorf("pull must have a valid latest submission") } isFormatPatch := patchutil.IsFormatPatch(latestSubmission.Patch) // title and body can only be empty if the patch is a format-patch if !isFormatPatch { if pull.Title == "" { return fmt.Errorf("pull title is empty (required for non-format-patch pulls)") } if pull.Body == "" { return fmt.Errorf("pull body is empty (required for non-format-patch pulls)") } if st := strings.TrimSpace(sanitizer.SanitizeDescription(pull.Title)); st == "" { return fmt.Errorf("title is empty after HTML sanitization") } if sb := strings.TrimSpace(sanitizer.SanitizeDefault(pull.Body)); sb == "" { return fmt.Errorf("body is empty after HTML sanitization") } } return nil } func PullFromRecord(did, rkey string, record tangled.RepoPull, blobs []io.Reader) (*Pull, error) { created, err := time.Parse(time.RFC3339, record.CreatedAt) if err != nil { return nil, fmt.Errorf("invalid createdAt: %w", err) } body := "" if record.Body != nil { body = *record.Body } var mentions []syntax.DID for _, m := range record.Mentions { if did, err := syntax.ParseDID(m); err == nil { mentions = append(mentions, did) } } var targetRepoDid syntax.DID var targetBranch string if record.Target != nil { did, err := syntax.ParseDID(record.Target.Repo) if err != nil { return nil, fmt.Errorf("invalid target.repo did: %w", err) } targetRepoDid = did targetBranch = record.Target.Branch } var pullSource *PullSource if record.Source != nil { pullSource = &PullSource{ Branch: record.Source.Branch, } if record.Source.Repo != nil { did, err := syntax.ParseDID(*record.Source.Repo) if err != nil { return nil, fmt.Errorf("invalid source.repo did: %w", err) } pullSource.RepoDid = &did } } var dependentOn *syntax.ATURI if record.DependentOn != nil { uri, err := syntax.ParseATURI(*record.DependentOn) if err != nil { return nil, fmt.Errorf("invalid dependentOn aturi: %w", err) } dependentOn = &uri } var submissions []*PullSubmission for i, s := range record.Rounds { var blob io.Reader if i < len(blobs) { blob = blobs[i] } submission, err := PullSubmissionFromRecord(did, rkey, i, s, blob) if err != nil { return nil, fmt.Errorf("invalid pull round at index %d: %w", i, err) } submissions = append(submissions, submission) } return &Pull{ RepoDid: targetRepoDid, OwnerDid: did, Rkey: rkey, Title: record.Title, Body: body, TargetBranch: targetBranch, PullSource: pullSource, State: PullOpen, Submissions: submissions, Created: created, DependentOn: dependentOn, }, nil } func PullSubmissionFromRecord(did, rkey string, roundNumber int, round *tangled.RepoPull_Round, blob io.Reader) (*PullSubmission, error) { created, err := time.Parse(time.RFC3339, round.CreatedAt) if err != nil { return nil, fmt.Errorf("invalid createdAt: %w", err) } var patch, sourceRev string if blob != nil { p, err := extractGzip(blob) if err != nil { return nil, fmt.Errorf("failed to extract gzip: %w", err) } patch = p if patchutil.IsFormatPatch(p) { patches, err := patchutil.ExtractPatches(p) if err != nil { return nil, fmt.Errorf("failed to extract patches: %w", err) } for _, part := range patches { sourceRev = part.SHA } } } return &PullSubmission{ PullAt: syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoPullNSID, rkey)), RoundNumber: roundNumber, Blob: *round.PatchBlob, Created: created, Patch: patch, SourceRev: sourceRev, }, nil } type PullSource struct { Branch string RepoDid *syntax.DID // optionally populate this for reverse mappings Repo *Repo } func (s *PullSource) AsRecord() *tangled.RepoPull_Source { if s == nil { return nil } var repo *string if s.RepoDid != nil { r := s.RepoDid.String() repo = &r } return &tangled.RepoPull_Source{ Branch: s.Branch, Repo: repo, } } type PullSubmission struct { // ids ID int // at ids PullAt syntax.ATURI // content RoundNumber int Blob lexutil.LexBlob Patch string Combined string Comments []Comment SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs // meta Created time.Time } func (p *Pull) TotalComments() int { total := 0 for _, s := range p.Submissions { total += len(s.Comments) } return total } func (p *Pull) LastRoundNumber() int { return len(p.Submissions) - 1 } func (p *Pull) LatestSubmission() *PullSubmission { return p.Submissions[p.LastRoundNumber()] } func (p *Pull) LatestPatch() string { return p.LatestSubmission().Patch } func (p *Pull) LatestSha() string { return p.LatestSubmission().SourceRev } func (p *Pull) AtUri() syntax.ATURI { return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) } func (p *Pull) IsPatchBased() bool { return p.PullSource == nil } func (p *Pull) IsBranchBased() bool { if p.PullSource != nil { if p.PullSource.RepoDid != nil { return *p.PullSource.RepoDid == p.RepoDid } // no repo specified return true } return false } func (p *Pull) IsForkBased() bool { if p.PullSource != nil { if p.PullSource.RepoDid != nil { // make sure repos are different return *p.PullSource.RepoDid != p.RepoDid } } return false } func (p *Pull) Participants() []syntax.DID { participantSet := make(map[syntax.DID]struct{}) participants := []syntax.DID{} addParticipant := func(did syntax.DID) { if _, exists := participantSet[did]; !exists { participantSet[did] = struct{}{} participants = append(participants, did) } } addParticipant(syntax.DID(p.OwnerDid)) for _, s := range p.Submissions { for _, sp := range s.Participants() { addParticipant(syntax.DID(sp)) } } return participants } func (s PullSubmission) IsFormatPatch() bool { return patchutil.IsFormatPatch(s.Patch) } func (s PullSubmission) AsFormatPatch() []types.FormatPatch { patches, err := patchutil.ExtractPatches(s.Patch) if err != nil { log.Println("error extracting patches from submission:", err) return []types.FormatPatch{} } return patches } // empty if invalid, not otherwise func (s PullSubmission) ChangeId() string { patches := s.AsFormatPatch() if len(patches) != 1 { return "" } c, err := patches[0].ChangeId() if err != nil { return "" } return c } func (s *PullSubmission) Participants() []string { participantSet := make(map[string]struct{}) participants := []string{} addParticipant := func(did string) { if _, exists := participantSet[did]; !exists { participantSet[did] = struct{}{} participants = append(participants, did) } } addParticipant(s.PullAt.Authority().String()) for _, c := range s.Comments { addParticipant(c.Did.String()) } return participants } func (s PullSubmission) CombinedPatch() string { if s.Combined == "" { return s.Patch } return s.Combined } func (s *PullSubmission) GetBlob() *lexutil.LexBlob { if !s.Blob.Ref.Defined() { return nil } return &s.Blob } func (s *PullSubmission) AsRecord() *tangled.RepoPull_Round { return &tangled.RepoPull_Round{ CreatedAt: s.Created.Format(time.RFC3339), PatchBlob: s.GetBlob(), } } type Stack []*Pull // position of this pull in the stack func (stack Stack) Position(pull *Pull) int { return slices.IndexFunc(stack, func(p *Pull) bool { return p.AtUri() == pull.AtUri() }) } // all pulls below this pull (including self) in this stack // // nil if this pull does not belong to this stack func (stack Stack) Below(pull *Pull) Stack { position := stack.Position(pull) if position < 0 { return nil } return stack[position:] } // all pulls below this pull (excluding self) in this stack func (stack Stack) StrictlyBelow(pull *Pull) Stack { below := stack.Below(pull) if len(below) > 0 { return below[1:] } return nil } // all pulls above this pull (including self) in this stack func (stack Stack) Above(pull *Pull) Stack { position := stack.Position(pull) if position < 0 { return nil } return stack[:position+1] } // all pulls below this pull (excluding self) in this stack func (stack Stack) StrictlyAbove(pull *Pull) Stack { above := stack.Above(pull) if len(above) > 0 { return above[:len(above)-1] } return nil } // the combined format-patches of all the newest submissions in this stack func (stack Stack) CombinedPatch() string { // go in reverse order because the bottom of the stack is the last element in the slice var combined strings.Builder for idx := range stack { pull := stack[len(stack)-1-idx] combined.WriteString(pull.LatestPatch()) combined.WriteString("\n") } return combined.String() } // filter out PRs that are "active" // // PRs that are still open are active func (stack Stack) Mergeable() Stack { var mergeable Stack for _, p := range stack { // stop at the first merged PR if p.State == PullMerged || p.State == PullClosed { break } // skip over abandoned PRs if p.State != PullAbandoned { mergeable = append(mergeable, p) } } return mergeable } type BranchDeleteStatus struct { Repo *Repo Branch string } func extractGzip(blob io.Reader) (string, error) { var b bytes.Buffer r, err := gzip.NewReader(blob) if err != nil { return "", err } defer r.Close() const maxSize = 15 * 1024 * 1024 limitedReader := io.LimitReader(r, maxSize) _, err = io.Copy(&b, limitedReader) if err != nil { return "", err } return b.String(), nil }