package models import ( "bytes" "compress/gzip" "encoding/hex" "fmt" "io" "maps" "slices" "strings" "time" "tangled.org/core/api/tangled" "tangled.org/core/appview/pages/markup/sanitizer" "tangled.org/core/patchutil" "github.com/bluesky-social/indigo/atproto/syntax" ) 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 { ID int64 // appview-local PR id. Used for quick referencing OwnerDid syntax.DID Rkey syntax.RecordKey Cid syntax.CID RepoDid syntax.DID PullId int64 Title string Body string TargetBranch string SourceRepo syntax.DID SourceBranch *string Versions []PullVersion Created time.Time State PullState // optionally, populate this when querying for reverse mappings Labels LabelState Repo *Repo } func (p *Pull) AtUri() syntax.ATURI { return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) } func (p *Pull) AsRecord() tangled.RepoPull { sourceRepo := p.SourceRepo.String() var sourceBranch string if p.SourceBranch != nil { sourceBranch = *p.SourceBranch } versions := make([]*tangled.RepoPull_Version, len(p.Versions)) for i, v := range p.Versions { var base *string if v.Base != "" { base = &v.Base } versions[i] = &tangled.RepoPull_Version{ Base: base, Head: v.Head, CreatedAt: v.Created.Format(time.RFC3339), } } return tangled.RepoPull{ Title: p.Title, Body: &p.Body, Target: &tangled.RepoPull_Target{ Repo: p.RepoDid.String(), Branch: p.TargetBranch, }, Source: &tangled.RepoPull_Source{ Repo: &sourceRepo, Branch: sourceBranch, }, Versions: versions, CreatedAt: p.Created.Format(time.RFC3339), } } func (p *Pull) Validate() error { if len(p.Versions) == 0 { return fmt.Errorf("pull must have at least one version") } if p.Title == "" { return fmt.Errorf("pull title is empty (required for non-format-patch pulls)") } if st := strings.TrimSpace(sanitizer.SanitizeDescription(p.Title)); st == "" { return fmt.Errorf("title is empty after HTML sanitization") } for i, version := range p.Versions { if err := version.Validate(); err != nil { return fmt.Errorf("versions[%d]: %w", i, err) } } return nil } func (v *PullVersion) Validate() error { if v.Base != "" && !IsHash(v.Base) { return fmt.Errorf("invalid base commit id: %q", v.Base) } if !IsHash(v.Head) { return fmt.Errorf("invalid head commit id: %q", v.Head) } return nil } func PullFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.RepoPull, versions []PullVersion) (*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 sourceRepo syntax.DID var sourceBranch *string if record.Source != nil { 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) } sourceRepo = did } if record.Source.Branch != "" { sourceBranch = new(string) *sourceBranch = record.Source.Branch } } return &Pull{ ID: -1, // uninitialized OwnerDid: did, Rkey: rkey, Cid: cid, RepoDid: targetRepoDid, PullId: 0, // uninitialized Title: record.Title, Body: body, TargetBranch: targetBranch, SourceRepo: sourceRepo, SourceBranch: sourceBranch, Versions: versions, Created: created, State: PullOpen, // default to open }, nil } func PullVersionFromRecord(idx int, record *tangled.RepoPull_Version) (PullVersion, error) { created, err := time.Parse(time.RFC3339, record.CreatedAt) if err != nil { return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err) } var base string if record.Base != nil { base = *record.Base } return PullVersion{ ID: idx, Base: base, Head: record.Head, Created: created, }, nil } func PullVersionFromLegacy(idx int, record *tangled.RepoPull_Round, reader io.Reader) (PullVersion, error) { created, err := time.Parse(time.RFC3339, record.CreatedAt) if err != nil { return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err) } patch, err := extractGzip(reader) if err != nil { return PullVersion{}, fmt.Errorf("failed to extract gzip: %w", err) } if !patchutil.IsFormatPatch(patch) { return PullVersion{}, fmt.Errorf("only format-patch patch is supported") } var sourceRev string patches, err := patchutil.ExtractPatches(patch) if err != nil { return PullVersion{}, fmt.Errorf("failed to extract patches: %w", err) } for _, part := range patches { sourceRev = part.SHA } if sourceRev == "" { return PullVersion{}, fmt.Errorf("source rev is missing") } return PullVersion{ ID: idx, Base: "", Head: sourceRev, Created: created, }, 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 PullVersion struct { ID int Head string // head commit ID Base string // base commit ID (for combined interdiff) Created time.Time // reverse mappings Comments []Comment } func (p *Pull) TotalComments() int { total := 0 for _, s := range p.Versions { total += len(s.Comments) } return total } func (p *Pull) GetVersion(id int) (PullVersion, bool) { for _, version := range p.Versions { if version.ID == id { return version, true } } return PullVersion{}, false } func (p *Pull) LatestVersionNumber() int { return len(p.Versions) - 1 } func (p *Pull) LatestVersion() PullVersion { return p.Versions[p.LatestVersionNumber()] } func (p *Pull) LatestSha() string { return p.LatestVersion().Head } func (p *Pull) IsForkBased() bool { return p.RepoDid != p.SourceRepo } func (p *Pull) Participants() []syntax.DID { participants := make(map[syntax.DID]struct{}) participants[p.OwnerDid] = struct{}{} for _, v := range p.Versions { for _, sp := range v.Participants() { participants[sp] = struct{}{} } } return slices.Collect(maps.Keys(participants)) } func (s *PullVersion) Participants() []syntax.DID { participants := make(map[syntax.DID]struct{}) for _, c := range s.Comments { participants[c.Did] = struct{}{} } return slices.Collect(maps.Keys(participants)) } 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 } func IsHash(s string) bool { switch len(s) { case 40: // SHA1 case 64: // SHA2 default: return false } _, err := hex.DecodeString(s) return err == nil }