This repository has no description
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}