1package models
2
3import (
4 "fmt"
5 "log"
6 "slices"
7 "strings"
8 "time"
9
10 "github.com/bluesky-social/indigo/atproto/syntax"
11 "tangled.org/core/api/tangled"
12 "tangled.org/core/patchutil"
13 "tangled.org/core/types"
14)
15
16type PullState int
17
18const (
19 PullClosed PullState = iota
20 PullOpen
21 PullMerged
22 PullDeleted
23)
24
25func (p PullState) String() string {
26 switch p {
27 case PullOpen:
28 return "open"
29 case PullMerged:
30 return "merged"
31 case PullClosed:
32 return "closed"
33 case PullDeleted:
34 return "deleted"
35 default:
36 return "closed"
37 }
38}
39
40func (p PullState) IsOpen() bool {
41 return p == PullOpen
42}
43func (p PullState) IsMerged() bool {
44 return p == PullMerged
45}
46func (p PullState) IsClosed() bool {
47 return p == PullClosed
48}
49func (p PullState) IsDeleted() bool {
50 return p == PullDeleted
51}
52
53type Pull struct {
54 // ids
55 ID int
56 PullId int
57
58 // at ids
59 RepoAt syntax.ATURI
60 OwnerDid string
61 Rkey string
62
63 // content
64 Title string
65 Body string
66 TargetBranch string
67 State PullState
68 Submissions []*PullSubmission
69
70 // stacking
71 StackId string // nullable string
72 ChangeId string // nullable string
73 ParentChangeId string // nullable string
74
75 // meta
76 Created time.Time
77 PullSource *PullSource
78
79 // optionally, populate this when querying for reverse mappings
80 Labels LabelState
81 Repo *Repo
82}
83
84func (p Pull) AsRecord() tangled.RepoPull {
85 var source *tangled.RepoPull_Source
86 if p.PullSource != nil {
87 s := p.PullSource.AsRecord()
88 source = &s
89 source.Sha = p.LatestSha()
90 }
91
92 record := tangled.RepoPull{
93 Title: p.Title,
94 Body: &p.Body,
95 CreatedAt: p.Created.Format(time.RFC3339),
96 Target: &tangled.RepoPull_Target{
97 Repo: p.RepoAt.String(),
98 Branch: p.TargetBranch,
99 },
100 Patch: p.LatestPatch(),
101 Source: source,
102 }
103 return record
104}
105
106type PullSource struct {
107 Branch string
108 RepoAt *syntax.ATURI
109
110 // optionally populate this for reverse mappings
111 Repo *Repo
112}
113
114func (p PullSource) AsRecord() tangled.RepoPull_Source {
115 var repoAt *string
116 if p.RepoAt != nil {
117 s := p.RepoAt.String()
118 repoAt = &s
119 }
120 record := tangled.RepoPull_Source{
121 Branch: p.Branch,
122 Repo: repoAt,
123 }
124 return record
125}
126
127type PullSubmission struct {
128 // ids
129 ID int
130
131 // at ids
132 PullAt syntax.ATURI
133
134 // content
135 RoundNumber int
136 Patch string
137 Comments []PullComment
138 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
139
140 // meta
141 Created time.Time
142}
143
144type PullComment struct {
145 // ids
146 ID int
147 PullId int
148 SubmissionId int
149
150 // at ids
151 RepoAt string
152 OwnerDid string
153 CommentAt string
154
155 // content
156 Body string
157
158 // meta
159 Created time.Time
160}
161
162func (p *Pull) LatestPatch() string {
163 latestSubmission := p.Submissions[p.LastRoundNumber()]
164 return latestSubmission.Patch
165}
166
167func (p *Pull) LatestSha() string {
168 latestSubmission := p.Submissions[p.LastRoundNumber()]
169 return latestSubmission.SourceRev
170}
171
172func (p *Pull) PullAt() syntax.ATURI {
173 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
174}
175
176func (p *Pull) LastRoundNumber() int {
177 return len(p.Submissions) - 1
178}
179
180func (p *Pull) IsPatchBased() bool {
181 return p.PullSource == nil
182}
183
184func (p *Pull) IsBranchBased() bool {
185 if p.PullSource != nil {
186 if p.PullSource.RepoAt != nil {
187 return p.PullSource.RepoAt == &p.RepoAt
188 } else {
189 // no repo specified
190 return true
191 }
192 }
193 return false
194}
195
196func (p *Pull) IsForkBased() bool {
197 if p.PullSource != nil {
198 if p.PullSource.RepoAt != nil {
199 // make sure repos are different
200 return p.PullSource.RepoAt != &p.RepoAt
201 }
202 }
203 return false
204}
205
206func (p *Pull) IsStacked() bool {
207 return p.StackId != ""
208}
209
210func (p *Pull) Participants() []string {
211 participantSet := make(map[string]struct{})
212 participants := []string{}
213
214 addParticipant := func(did string) {
215 if _, exists := participantSet[did]; !exists {
216 participantSet[did] = struct{}{}
217 participants = append(participants, did)
218 }
219 }
220
221 addParticipant(p.OwnerDid)
222
223 for _, s := range p.Submissions {
224 for _, sp := range s.Participants() {
225 addParticipant(sp)
226 }
227 }
228
229 return participants
230}
231
232func (s PullSubmission) IsFormatPatch() bool {
233 return patchutil.IsFormatPatch(s.Patch)
234}
235
236func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
237 patches, err := patchutil.ExtractPatches(s.Patch)
238 if err != nil {
239 log.Println("error extracting patches from submission:", err)
240 return []types.FormatPatch{}
241 }
242
243 return patches
244}
245
246func (s *PullSubmission) Participants() []string {
247 participantSet := make(map[string]struct{})
248 participants := []string{}
249
250 addParticipant := func(did string) {
251 if _, exists := participantSet[did]; !exists {
252 participantSet[did] = struct{}{}
253 participants = append(participants, did)
254 }
255 }
256
257 addParticipant(s.PullAt.Authority().String())
258
259 for _, c := range s.Comments {
260 addParticipant(c.OwnerDid)
261 }
262
263 return participants
264}
265
266type Stack []*Pull
267
268// position of this pull in the stack
269func (stack Stack) Position(pull *Pull) int {
270 return slices.IndexFunc(stack, func(p *Pull) bool {
271 return p.ChangeId == pull.ChangeId
272 })
273}
274
275// all pulls below this pull (including self) in this stack
276//
277// nil if this pull does not belong to this stack
278func (stack Stack) Below(pull *Pull) Stack {
279 position := stack.Position(pull)
280
281 if position < 0 {
282 return nil
283 }
284
285 return stack[position:]
286}
287
288// all pulls below this pull (excluding self) in this stack
289func (stack Stack) StrictlyBelow(pull *Pull) Stack {
290 below := stack.Below(pull)
291
292 if len(below) > 0 {
293 return below[1:]
294 }
295
296 return nil
297}
298
299// all pulls above this pull (including self) in this stack
300func (stack Stack) Above(pull *Pull) Stack {
301 position := stack.Position(pull)
302
303 if position < 0 {
304 return nil
305 }
306
307 return stack[:position+1]
308}
309
310// all pulls below this pull (excluding self) in this stack
311func (stack Stack) StrictlyAbove(pull *Pull) Stack {
312 above := stack.Above(pull)
313
314 if len(above) > 0 {
315 return above[:len(above)-1]
316 }
317
318 return nil
319}
320
321// the combined format-patches of all the newest submissions in this stack
322func (stack Stack) CombinedPatch() string {
323 // go in reverse order because the bottom of the stack is the last element in the slice
324 var combined strings.Builder
325 for idx := range stack {
326 pull := stack[len(stack)-1-idx]
327 combined.WriteString(pull.LatestPatch())
328 combined.WriteString("\n")
329 }
330 return combined.String()
331}
332
333// filter out PRs that are "active"
334//
335// PRs that are still open are active
336func (stack Stack) Mergeable() Stack {
337 var mergeable Stack
338
339 for _, p := range stack {
340 // stop at the first merged PR
341 if p.State == PullMerged || p.State == PullClosed {
342 break
343 }
344
345 // skip over deleted PRs
346 if p.State != PullDeleted {
347 mergeable = append(mergeable, p)
348 }
349 }
350
351 return mergeable
352}