This repository has no description
0

Configure Feed

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

core / appview / models / pull.go
8.2 kB 382 lines
1package models 2 3import ( 4 "bytes" 5 "compress/gzip" 6 "encoding/hex" 7 "fmt" 8 "io" 9 "maps" 10 "slices" 11 "strings" 12 "time" 13 14 "tangled.org/core/api/tangled" 15 "tangled.org/core/appview/pages/markup/sanitizer" 16 "tangled.org/core/patchutil" 17 18 "github.com/bluesky-social/indigo/atproto/syntax" 19) 20 21type PullState int 22 23const ( 24 PullClosed PullState = iota 25 PullOpen 26 PullMerged 27 PullAbandoned 28) 29 30func (p PullState) String() string { 31 switch p { 32 case PullOpen: 33 return "open" 34 case PullMerged: 35 return "merged" 36 case PullClosed: 37 return "closed" 38 case PullAbandoned: 39 return "abandoned" 40 default: 41 return "closed" 42 } 43} 44 45func (p PullState) IsOpen() bool { 46 return p == PullOpen 47} 48func (p PullState) IsMerged() bool { 49 return p == PullMerged 50} 51func (p PullState) IsClosed() bool { 52 return p == PullClosed 53} 54func (p PullState) IsAbandoned() bool { 55 return p == PullAbandoned 56} 57 58type Pull struct { 59 ID int64 // appview-local PR id. Used for quick referencing 60 OwnerDid syntax.DID 61 Rkey syntax.RecordKey 62 Cid syntax.CID 63 RepoDid syntax.DID 64 PullId int64 65 66 Title string 67 Body string 68 TargetBranch string 69 SourceRepo syntax.DID 70 SourceBranch *string 71 Versions []PullVersion 72 Created time.Time 73 74 State PullState 75 76 // optionally, populate this when querying for reverse mappings 77 Labels LabelState 78 Repo *Repo 79} 80 81func (p *Pull) AtUri() syntax.ATURI { 82 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 83} 84 85func (p *Pull) AsRecord() tangled.RepoPull { 86 sourceRepo := p.SourceRepo.String() 87 var sourceBranch string 88 if p.SourceBranch != nil { 89 sourceBranch = *p.SourceBranch 90 } 91 versions := make([]*tangled.RepoPull_Version, len(p.Versions)) 92 for i, v := range p.Versions { 93 var base *string 94 if v.Base != "" { 95 base = &v.Base 96 } 97 versions[i] = &tangled.RepoPull_Version{ 98 Base: base, 99 Head: v.Head, 100 CreatedAt: v.Created.Format(time.RFC3339), 101 } 102 } 103 return tangled.RepoPull{ 104 Title: p.Title, 105 Body: &p.Body, 106 Target: &tangled.RepoPull_Target{ 107 Repo: p.RepoDid.String(), 108 Branch: p.TargetBranch, 109 }, 110 Source: &tangled.RepoPull_Source{ 111 Repo: &sourceRepo, 112 Branch: sourceBranch, 113 }, 114 Versions: versions, 115 CreatedAt: p.Created.Format(time.RFC3339), 116 } 117} 118 119func (p *Pull) Validate() error { 120 if len(p.Versions) == 0 { 121 return fmt.Errorf("pull must have at least one version") 122 } 123 124 if p.Title == "" { 125 return fmt.Errorf("pull title is empty (required for non-format-patch pulls)") 126 } 127 if st := strings.TrimSpace(sanitizer.SanitizeDescription(p.Title)); st == "" { 128 return fmt.Errorf("title is empty after HTML sanitization") 129 } 130 131 for i, version := range p.Versions { 132 if err := version.Validate(); err != nil { 133 return fmt.Errorf("versions[%d]: %w", i, err) 134 } 135 } 136 return nil 137} 138 139func (v *PullVersion) Validate() error { 140 if v.Base != "" && !IsHash(v.Base) { 141 return fmt.Errorf("invalid base commit id: %q", v.Base) 142 } 143 if !IsHash(v.Head) { 144 return fmt.Errorf("invalid head commit id: %q", v.Head) 145 } 146 return nil 147} 148 149func PullFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.RepoPull, versions []PullVersion) (*Pull, error) { 150 created, err := time.Parse(time.RFC3339, record.CreatedAt) 151 if err != nil { 152 return nil, fmt.Errorf("invalid createdAt: %w", err) 153 } 154 155 body := "" 156 if record.Body != nil { 157 body = *record.Body 158 } 159 160 // var mentions []syntax.DID 161 // for _, m := range record.Mentions { 162 // if did, err := syntax.ParseDID(m); err == nil { 163 // mentions = append(mentions, did) 164 // } 165 // } 166 167 var targetRepoDid syntax.DID 168 var targetBranch string 169 if record.Target != nil { 170 did, err := syntax.ParseDID(record.Target.Repo) 171 if err != nil { 172 return nil, fmt.Errorf("invalid target.repo did: %w", err) 173 } 174 targetRepoDid = did 175 targetBranch = record.Target.Branch 176 } 177 178 var sourceRepo syntax.DID 179 var sourceBranch *string 180 if record.Source != nil { 181 if record.Source.Repo != nil { 182 did, err := syntax.ParseDID(*record.Source.Repo) 183 if err != nil { 184 return nil, fmt.Errorf("invalid source.repo did: %w", err) 185 } 186 sourceRepo = did 187 } 188 if record.Source.Branch != "" { 189 sourceBranch = new(string) 190 *sourceBranch = record.Source.Branch 191 } 192 } 193 194 return &Pull{ 195 ID: -1, // uninitialized 196 OwnerDid: did, 197 Rkey: rkey, 198 Cid: cid, 199 RepoDid: targetRepoDid, 200 PullId: 0, // uninitialized 201 202 Title: record.Title, 203 Body: body, 204 TargetBranch: targetBranch, 205 SourceRepo: sourceRepo, 206 SourceBranch: sourceBranch, 207 Versions: versions, 208 Created: created, 209 State: PullOpen, // default to open 210 }, nil 211} 212 213func PullVersionFromRecord(idx int, record *tangled.RepoPull_Version) (PullVersion, error) { 214 created, err := time.Parse(time.RFC3339, record.CreatedAt) 215 if err != nil { 216 return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err) 217 } 218 219 var base string 220 if record.Base != nil { 221 base = *record.Base 222 } 223 return PullVersion{ 224 ID: idx, 225 Base: base, 226 Head: record.Head, 227 Created: created, 228 }, nil 229} 230 231func PullVersionFromLegacy(idx int, record *tangled.RepoPull_Round, reader io.Reader) (PullVersion, error) { 232 created, err := time.Parse(time.RFC3339, record.CreatedAt) 233 if err != nil { 234 return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err) 235 } 236 237 patch, err := extractGzip(reader) 238 if err != nil { 239 return PullVersion{}, fmt.Errorf("failed to extract gzip: %w", err) 240 } 241 if !patchutil.IsFormatPatch(patch) { 242 return PullVersion{}, fmt.Errorf("only format-patch patch is supported") 243 } 244 245 var sourceRev string 246 patches, err := patchutil.ExtractPatches(patch) 247 if err != nil { 248 return PullVersion{}, fmt.Errorf("failed to extract patches: %w", err) 249 } 250 for _, part := range patches { 251 sourceRev = part.SHA 252 } 253 if sourceRev == "" { 254 return PullVersion{}, fmt.Errorf("source rev is missing") 255 } 256 return PullVersion{ 257 ID: idx, 258 Base: "", 259 Head: sourceRev, 260 Created: created, 261 }, nil 262} 263 264type PullSource struct { 265 Branch string 266 RepoDid *syntax.DID 267 268 // optionally populate this for reverse mappings 269 Repo *Repo 270} 271 272func (s *PullSource) AsRecord() *tangled.RepoPull_Source { 273 if s == nil { 274 return nil 275 } 276 var repo *string 277 if s.RepoDid != nil { 278 r := s.RepoDid.String() 279 repo = &r 280 } 281 return &tangled.RepoPull_Source{ 282 Branch: s.Branch, 283 Repo: repo, 284 } 285} 286 287type PullVersion struct { 288 ID int 289 Head string // head commit ID 290 Base string // base commit ID (for combined interdiff) 291 Created time.Time 292 293 // reverse mappings 294 Comments []Comment 295} 296 297func (p *Pull) TotalComments() int { 298 total := 0 299 for _, s := range p.Versions { 300 total += len(s.Comments) 301 } 302 return total 303} 304 305func (p *Pull) GetVersion(id int) (PullVersion, bool) { 306 for _, version := range p.Versions { 307 if version.ID == id { 308 return version, true 309 } 310 } 311 return PullVersion{}, false 312} 313 314func (p *Pull) LatestVersionNumber() int { 315 return len(p.Versions) - 1 316} 317 318func (p *Pull) LatestVersion() PullVersion { 319 return p.Versions[p.LatestVersionNumber()] 320} 321 322func (p *Pull) LatestSha() string { 323 return p.LatestVersion().Head 324} 325 326func (p *Pull) IsForkBased() bool { 327 return p.RepoDid != p.SourceRepo 328} 329 330func (p *Pull) Participants() []syntax.DID { 331 participants := make(map[syntax.DID]struct{}) 332 333 participants[p.OwnerDid] = struct{}{} 334 335 for _, v := range p.Versions { 336 for _, sp := range v.Participants() { 337 participants[sp] = struct{}{} 338 } 339 } 340 341 return slices.Collect(maps.Keys(participants)) 342} 343 344func (s *PullVersion) Participants() []syntax.DID { 345 participants := make(map[syntax.DID]struct{}) 346 347 for _, c := range s.Comments { 348 participants[c.Did] = struct{}{} 349 } 350 351 return slices.Collect(maps.Keys(participants)) 352} 353 354func extractGzip(blob io.Reader) (string, error) { 355 var b bytes.Buffer 356 r, err := gzip.NewReader(blob) 357 if err != nil { 358 return "", err 359 } 360 defer r.Close() 361 362 const maxSize = 15 * 1024 * 1024 363 limitedReader := io.LimitReader(r, maxSize) 364 365 _, err = io.Copy(&b, limitedReader) 366 if err != nil { 367 return "", err 368 } 369 370 return b.String(), nil 371} 372 373func IsHash(s string) bool { 374 switch len(s) { 375 case 40: // SHA1 376 case 64: // SHA2 377 default: 378 return false 379 } 380 _, err := hex.DecodeString(s) 381 return err == nil 382}