vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
c5902451
Keenan Nemetz
16d ago
|
1 | package metadata |
|
1eeca4e6
Keenan Nemetz
17d ago
|
2 | |
| 3 | import ( | |
|
38b30251
Keenan Nemetz
13d ago
|
4 | "errors" |
|
1eeca4e6
Keenan Nemetz
17d ago
|
5 | "fmt" |
|
307cbc2a
Keenan Nemetz
17d ago
|
6 | "strings" |
|
1eeca4e6
Keenan Nemetz
17d ago
|
7 | "time" |
| 8 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
9 | "rodent.software/vole/core/git" |
| 10 | voleid "rodent.software/vole/core/id" | |
| 11 | "rodent.software/vole/internal/format" | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
12 | ) |
| 13 | ||
| 14 | // New builds a request with a fresh ULID. Nothing is assigned server-side, so | |
| 15 | // concurrent authors never contend. | |
|
62576fa8
Keenan Nemetz
14d ago
|
16 | func NewRequest(title, body, author, base, head string, now time.Time) (*Request, error) { |
|
c5902451
Keenan Nemetz
16d ago
|
17 | id, err := voleid.New(now) |
|
1eeca4e6
Keenan Nemetz
17d ago
|
18 | if err != nil { |
| 19 | return nil, err | |
| 20 | } | |
| 21 | r := &Request{ | |
| 22 | ID: id, | |
|
307cbc2a
Keenan Nemetz
17d ago
|
23 | Title: strings.TrimSpace(title), |
| 24 | Author: strings.TrimSpace(author), | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
25 | Base: base, |
| 26 | Head: head, | |
| 27 | State: "open", | |
| 28 | Created: now.UTC(), | |
| 29 | Description: body, | |
| 30 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
31 | if err := validateRequestForWrite(r); err != nil { |
|
1eeca4e6
Keenan Nemetz
17d ago
|
32 | return nil, err |
| 33 | } | |
| 34 | return r, nil | |
| 35 | } | |
| 36 | ||
|
c5902451
Keenan Nemetz
16d ago
|
37 | func validateRequestForWrite(r *Request) error { |
|
1eeca4e6
Keenan Nemetz
17d ago
|
38 | if r.Title == "" { |
| 39 | return fmt.Errorf("requests: title is required") | |
| 40 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
41 | if len(r.Title) > maxRequestTitleLen { |
| 42 | return fmt.Errorf("requests: title is %d bytes, limit %d", len(r.Title), maxRequestTitleLen) | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
43 | } |
| 44 | if r.Author == "" { | |
| 45 | return fmt.Errorf("requests: author is required") | |
| 46 | } | |
|
4581690d
Keenan Nemetz
13d ago
|
47 | if problems := bodyProblems("body", r.Description); len(problems) > 0 { |
| 48 | return fmt.Errorf("requests: %s", problems[0]) | |
| 49 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
50 | if !assigneeRE.MatchString(r.Author) { |
| 51 | return fmt.Errorf("requests: author %q is not an account name", r.Author) | |
|
31df8bae
Keenan Nemetz
17d ago
|
52 | } |
|
e050e14a
Keenan Nemetz
13d ago
|
53 | if !validBranchName(r.Base) { |
|
1eeca4e6
Keenan Nemetz
17d ago
|
54 | return fmt.Errorf("requests: base %q is not a branch name", r.Base) |
| 55 | } | |
| 56 | if r.Head != "" && !shaRE.MatchString(r.Head) { | |
| 57 | return fmt.Errorf("requests: head %q is not a full commit id", r.Head) | |
| 58 | } | |
|
8e1005eb
Keenan Nemetz
13d ago
|
59 | if r.HeadRef != "" { |
|
e050e14a
Keenan Nemetz
13d ago
|
60 | if !validBranchName(r.HeadRef) { |
|
8e1005eb
Keenan Nemetz
13d ago
|
61 | return fmt.Errorf("requests: head_ref %q is not a branch name", r.HeadRef) |
| 62 | } | |
| 63 | if r.Head == "" { | |
| 64 | return fmt.Errorf("requests: head_ref names a branch, so head must name the commit it pointed at") | |
| 65 | } | |
| 66 | } | |
|
2841d922
Keenan Nemetz
13d ago
|
67 | if r.MergedBase != "" && !shaRE.MatchString(r.MergedBase) { |
| 68 | return fmt.Errorf("requests: merged base %q is not a full commit id", r.MergedBase) | |
| 69 | } | |
|
183d48e5
Keenan Nemetz
13d ago
|
70 | if _, problems := parseCloses(r.Closes, "request"); len(problems) > 0 { |
| 71 | return fmt.Errorf("%s", strings.Join(problems, "; ")) | |
| 72 | } | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
73 | return nil |
| 74 | } | |
| 75 | ||
|
307cbc2a
Keenan Nemetz
17d ago
|
76 | // renderRequest writes a request as one file: the head names the branch and the |
| 77 | // commit under review, the body says why. | |
| 78 | func renderRequest(r *Request) ([]byte, error) { | |
|
c5902451
Keenan Nemetz
16d ago
|
79 | d := NewDoc() |
|
307cbc2a
Keenan Nemetz
17d ago
|
80 | d.Text("title", r.Title) |
| 81 | d.Word("state", r.State) | |
| 82 | d.Word("author", r.Author) | |
| 83 | d.Word("base", r.Base) | |
| 84 | d.Word("head", r.Head) | |
|
8e1005eb
Keenan Nemetz
13d ago
|
85 | if r.HeadRef != "" { |
| 86 | d.Word("head_ref", r.HeadRef) | |
| 87 | } | |
|
183d48e5
Keenan Nemetz
13d ago
|
88 | d.List("closes", r.Closes) |
|
307cbc2a
Keenan Nemetz
17d ago
|
89 | d.Word("created", r.Created.UTC().Format(time.RFC3339)) |
|
38b30251
Keenan Nemetz
13d ago
|
90 | if r.State == "merged" { |
|
2841d922
Keenan Nemetz
13d ago
|
91 | if r.MergedBase != "" { |
| 92 | d.Word("merged_base", r.MergedBase) | |
| 93 | } | |
|
38b30251
Keenan Nemetz
13d ago
|
94 | d.Word("merged_head", r.MergedHead) |
| 95 | d.Word("merged_by", r.MergedBy) | |
| 96 | d.Word("merged_at", r.MergedAt.UTC().Format(time.RFC3339)) | |
| 97 | } | |
|
307cbc2a
Keenan Nemetz
17d ago
|
98 | return d.Bytes(strings.TrimRight(r.Description, "\n")) |
| 99 | } | |
| 100 | ||
| 101 | // renderApproval writes an approval: three constrained fields and no body. | |
| 102 | func renderApproval(a Approval) ([]byte, error) { | |
|
c5902451
Keenan Nemetz
16d ago
|
103 | d := NewDoc() |
|
307cbc2a
Keenan Nemetz
17d ago
|
104 | d.Word("base", a.Base) |
| 105 | d.Word("head", a.Head) | |
| 106 | d.Word("created", a.Created.UTC().Format(time.RFC3339)) | |
| 107 | return d.Bytes("") | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
108 | } |
| 109 | ||
| 110 | // Create writes a new request onto the metadata branch. | |
|
c5902451
Keenan Nemetz
16d ago
|
111 | func CreateRequest(g *git.Repo, r *Request, actor string) error { |
| 112 | if err := validateRequestForWrite(r); err != nil { | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
113 | return err |
| 114 | } | |
|
307cbc2a
Keenan Nemetz
17d ago
|
115 | raw, err := renderRequest(r) |
|
1eeca4e6
Keenan Nemetz
17d ago
|
116 | if err != nil { |
| 117 | return err | |
| 118 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
119 | base := fmt.Sprintf("%s/%s/", ChangesDir, r.ID) |
|
c5902451
Keenan Nemetz
16d ago
|
120 | _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
|
b492b557
Keenan Nemetz
3d ago
|
121 | if _, exists := files[base+changeName]; exists { |
|
1eeca4e6
Keenan Nemetz
17d ago
|
122 | return fmt.Errorf("requests: %s already exists", r.ID) |
| 123 | } | |
|
122cbe4f
Keenan Nemetz
7d ago
|
124 | // A link is a promise that a merge closes the issue, so it has to name one. |
| 125 | // Checking it against the tree this commit replaces means a typo is refused | |
| 126 | // while the author is still looking at it, instead of at the landing of a | |
| 127 | // request they waited on. | |
| 128 | if err := issuesExist(files, r.Closes); err != nil { | |
| 129 | return err | |
| 130 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
131 | files[base+changeName] = raw |
|
1eeca4e6
Keenan Nemetz
17d ago
|
132 | return nil |
| 133 | }, fmt.Sprintf("request: %s (%s)", r.Title, r.ID), actor) | |
| 134 | return err | |
| 135 | } | |
| 136 | ||
|
c9202f7f
Keenan Nemetz
9d ago
|
137 | // Get returns one request by id. It reads that request's files, not the whole |
| 138 | // branch: a page about one change should not pay for every other change. | |
|
c5902451
Keenan Nemetz
16d ago
|
139 | func GetRequest(g *git.Repo, id string) (*Request, error) { |
|
c9202f7f
Keenan Nemetz
9d ago
|
140 | commit, err := g.RevParse(Ref) |
|
1eeca4e6
Keenan Nemetz
17d ago
|
141 | if err != nil { |
| 142 | return nil, err | |
| 143 | } | |
|
c9202f7f
Keenan Nemetz
9d ago
|
144 | req, found, err := LoadRequestAt(g, commit, id) |
| 145 | if err != nil { | |
| 146 | return nil, err | |
| 147 | } | |
| 148 | if !found { | |
| 149 | return nil, fmt.Errorf("requests: no request %s", id) | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
150 | } |
|
c9202f7f
Keenan Nemetz
9d ago
|
151 | return req, nil |
|
1eeca4e6
Keenan Nemetz
17d ago
|
152 | } |
| 153 | ||
|
a4e7a99a
Keenan Nemetz
5d ago
|
154 | // SetRequestDetails replaces the title and description of a request. The prose is |
| 155 | // not what an approval names — that is the base and head — so correcting it does | |
| 156 | // not start the review over, and it stays possible after the request is closed or | |
| 157 | // merged, when a record with a typo is still worth fixing. The writer validates the | |
| 158 | // whole record, so an edit cannot leave a request the reader would refuse. | |
| 159 | func SetRequestDetails(g *git.Repo, id, title, body, actor string) (*Request, error) { | |
| 160 | title = strings.TrimSpace(title) | |
| 161 | var out *Request | |
|
b492b557
Keenan Nemetz
3d ago
|
162 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
|
a4e7a99a
Keenan Nemetz
5d ago
|
163 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 164 | r, err := loadFrom(files, id) | |
| 165 | if err != nil { | |
| 166 | return err | |
| 167 | } | |
| 168 | if r.Title == title && r.Description == body { | |
| 169 | out = r | |
| 170 | return ErrNoChange | |
| 171 | } | |
| 172 | r.Title = title | |
| 173 | r.Description = body | |
| 174 | if err := validateRequestForWrite(r); err != nil { | |
| 175 | return err | |
| 176 | } | |
| 177 | raw, err := renderRequest(r) | |
| 178 | if err != nil { | |
| 179 | return err | |
| 180 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
181 | files[base+changeName] = raw |
|
a4e7a99a
Keenan Nemetz
5d ago
|
182 | out = r |
| 183 | return nil | |
| 184 | }, fmt.Sprintf("request: update %s", id), actor) | |
| 185 | if errors.Is(err, ErrNoChange) { | |
| 186 | return out, nil | |
| 187 | } | |
| 188 | return out, err | |
| 189 | } | |
| 190 | ||
|
e050e14a
Keenan Nemetz
13d ago
|
191 | // SetRequestHead repoints an open request at a commit and, optionally, at the |
| 192 | // branch it should follow. A non-empty headRef makes the request follow that | |
| 193 | // branch, so the next push moves the head again; an empty one pins it to head. | |
| 194 | // Approvals stay in the tree but stop matching when the head moves, because they | |
| 195 | // name the commit they reviewed. | |
|
53ff967b
Keenan Nemetz
13d ago
|
196 | // |
| 197 | // Only an open request may move: a merged request's head is the record of what | |
| 198 | // landed, and a closed one is not going to land at all. Refusing here rather than | |
| 199 | // in each caller is what keeps the CLI and the browser on the same rule. | |
|
e050e14a
Keenan Nemetz
13d ago
|
200 | func SetRequestHead(g *git.Repo, id, head, headRef, actor string) (*Request, error) { |
|
1eeca4e6
Keenan Nemetz
17d ago
|
201 | var out *Request |
|
b492b557
Keenan Nemetz
3d ago
|
202 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
|
e050e14a
Keenan Nemetz
13d ago
|
203 | message := fmt.Sprintf("request: %s head -> %s", id, format.SHA(head)) |
| 204 | if headRef != "" { | |
| 205 | message = fmt.Sprintf("request: %s follows %s at %s", id, headRef, format.SHA(head)) | |
| 206 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
207 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
|
1eeca4e6
Keenan Nemetz
17d ago
|
208 | r, err := loadFrom(files, id) |
| 209 | if err != nil { | |
| 210 | return err | |
| 211 | } | |
|
53ff967b
Keenan Nemetz
13d ago
|
212 | if r.State != "open" { |
| 213 | return fmt.Errorf("requests: %s is %s, so its head cannot move", id, r.State) | |
| 214 | } | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
215 | r.Head = head |
|
e050e14a
Keenan Nemetz
13d ago
|
216 | r.HeadRef = headRef |
|
c5902451
Keenan Nemetz
16d ago
|
217 | if err := validateRequestForWrite(r); err != nil { |
|
1eeca4e6
Keenan Nemetz
17d ago
|
218 | return err |
| 219 | } | |
|
307cbc2a
Keenan Nemetz
17d ago
|
220 | raw, err := renderRequest(r) |
|
1eeca4e6
Keenan Nemetz
17d ago
|
221 | if err != nil { |
| 222 | return err | |
| 223 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
224 | files[base+changeName] = raw |
|
1eeca4e6
Keenan Nemetz
17d ago
|
225 | out = r |
| 226 | return nil | |
|
e050e14a
Keenan Nemetz
13d ago
|
227 | }, message, actor) |
| 228 | return out, err | |
| 229 | } | |
| 230 | ||
| 231 | // SetRequestBase retargets an open request at another base branch. It is how a | |
| 232 | // stacked request is repointed when the branch below it lands: the diff is now | |
| 233 | // against the new base, and every approval named the old base, so the review starts | |
| 234 | // over. | |
| 235 | // | |
| 236 | // Only an open request may move: a merged request's base is the record of what it | |
| 237 | // landed on, and a closed one is not going to land at all. | |
| 238 | func SetRequestBase(g *git.Repo, id, newBase, actor string) (*Request, error) { | |
| 239 | var out *Request | |
|
b492b557
Keenan Nemetz
3d ago
|
240 | dir := fmt.Sprintf("%s/%s/", ChangesDir, id) |
|
e050e14a
Keenan Nemetz
13d ago
|
241 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 242 | r, err := loadFrom(files, id) | |
| 243 | if err != nil { | |
| 244 | return err | |
| 245 | } | |
| 246 | if r.State != "open" { | |
| 247 | return fmt.Errorf("requests: %s is %s, so its base cannot move", id, r.State) | |
| 248 | } | |
| 249 | if r.Base == newBase { | |
| 250 | out = r | |
| 251 | return ErrNoChange | |
| 252 | } | |
| 253 | r.Base = newBase | |
| 254 | if err := validateRequestForWrite(r); err != nil { | |
| 255 | return err | |
| 256 | } | |
| 257 | raw, err := renderRequest(r) | |
| 258 | if err != nil { | |
| 259 | return err | |
| 260 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
261 | files[dir+changeName] = raw |
|
e050e14a
Keenan Nemetz
13d ago
|
262 | out = r |
| 263 | return nil | |
| 264 | }, fmt.Sprintf("request: %s base -> %s", id, newBase), actor) | |
| 265 | if errors.Is(err, ErrNoChange) { | |
| 266 | return out, nil | |
| 267 | } | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
268 | return out, err |
| 269 | } | |
| 270 | ||
|
41d1fee9
Keenan Nemetz
13d ago
|
271 | // SetRequestTarget moves an open request's base and the branch it follows in one |
| 272 | // metadata commit. It is the writer behind the request page's single target control: | |
| 273 | // changing both together avoids a request that briefly points at a base it was never | |
| 274 | // reviewed against. Approvals name the old base and commit, so either change makes | |
| 275 | // them stale. | |
| 276 | func SetRequestTarget(g *git.Repo, id, newBase, newHead, newHeadRef, actor string) (*Request, error) { | |
| 277 | var out *Request | |
|
b492b557
Keenan Nemetz
3d ago
|
278 | dir := fmt.Sprintf("%s/%s/", ChangesDir, id) |
|
41d1fee9
Keenan Nemetz
13d ago
|
279 | target := newHeadRef |
| 280 | if target == "" { | |
| 281 | target = format.SHA(newHead) | |
| 282 | } | |
| 283 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { | |
| 284 | r, err := loadFrom(files, id) | |
| 285 | if err != nil { | |
| 286 | return err | |
| 287 | } | |
| 288 | if r.State != "open" { | |
| 289 | return fmt.Errorf("requests: %s is %s, so its target cannot move", id, r.State) | |
| 290 | } | |
| 291 | if r.Base == newBase && r.Head == newHead && r.HeadRef == newHeadRef { | |
| 292 | out = r | |
| 293 | return ErrNoChange | |
| 294 | } | |
| 295 | r.Base = newBase | |
| 296 | r.Head = newHead | |
| 297 | r.HeadRef = newHeadRef | |
| 298 | if err := validateRequestForWrite(r); err != nil { | |
| 299 | return err | |
| 300 | } | |
| 301 | raw, err := renderRequest(r) | |
| 302 | if err != nil { | |
| 303 | return err | |
| 304 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
305 | files[dir+changeName] = raw |
|
41d1fee9
Keenan Nemetz
13d ago
|
306 | out = r |
| 307 | return nil | |
| 308 | }, fmt.Sprintf("request: %s target %s <- %s", id, newBase, target), actor) | |
| 309 | if errors.Is(err, ErrNoChange) { | |
| 310 | return out, nil | |
| 311 | } | |
| 312 | return out, err | |
| 313 | } | |
| 314 | ||
|
e050e14a
Keenan Nemetz
13d ago
|
315 | // SyncBranchHead moves every open request that follows branch to head. It is the |
| 316 | // server-side half of following a branch: post-receive calls it after the ref has | |
| 317 | // moved, so the request names the tip a reviewer will see. It changes only head; | |
| 318 | // approvals of the old tip stay where their owners wrote them and stop matching, | |
| 319 | // and checks are keyed by commit, so the new tip starts without any. | |
| 320 | // | |
| 321 | // This is deliberately separate from SetRequestHead: it is the server reacting to | |
| 322 | // a push, not an account moving a request, so it does not take an actor's | |
| 323 | // permission to edit the request. The only field it can change is head, and only | |
| 324 | // for a request that already named this branch. | |
| 325 | func SyncBranchHead(g *git.Repo, branch, head, actor string) (int, error) { | |
| 326 | if branch == "" || !shaRE.MatchString(head) { | |
| 327 | return 0, nil | |
| 328 | } | |
| 329 | updated := 0 | |
| 330 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { | |
| 331 | // CommitFiles re-runs this on a lost race, so the count is per attempt. | |
|
e535ca55
Keenan Nemetz
4d ago
|
332 | n, err := followBranch(files, branch, head) |
|
e050e14a
Keenan Nemetz
13d ago
|
333 | if err != nil { |
|
e535ca55
Keenan Nemetz
4d ago
|
334 | return err |
| 335 | } | |
| 336 | updated = n | |
| 337 | if n == 0 { | |
|
e050e14a
Keenan Nemetz
13d ago
|
338 | return ErrNoChange |
| 339 | } | |
| 340 | return nil | |
| 341 | }, fmt.Sprintf("request: follow %s at %s", branch, format.SHA(head)), actor) | |
| 342 | if errors.Is(err, ErrNoChange) { | |
| 343 | return 0, nil | |
| 344 | } | |
| 345 | if err != nil { | |
| 346 | return 0, err | |
| 347 | } | |
| 348 | return updated, nil | |
| 349 | } | |
| 350 | ||
|
e535ca55
Keenan Nemetz
4d ago
|
351 | // followBranch moves every open request that follows branch onto head, writing |
| 352 | // the changed request files into an open commit. It is SyncBranchHead's body, | |
| 353 | // shared with the merge mark: a landing moves its base branch without running | |
| 354 | // post-receive, so it has to move the requests following that branch in the same | |
| 355 | // commit that marks it merged. | |
| 356 | func followBranch(files map[string][]byte, branch, head string) (int, error) { | |
| 357 | if branch == "" || !shaRE.MatchString(head) { | |
| 358 | return 0, nil | |
| 359 | } | |
| 360 | contents := make(map[string]string) | |
| 361 | for path, raw := range files { | |
|
b492b557
Keenan Nemetz
3d ago
|
362 | if strings.HasPrefix(path, ChangesDir+"/") { |
|
e535ca55
Keenan Nemetz
4d ago
|
363 | contents[path] = string(raw) |
| 364 | } | |
| 365 | } | |
| 366 | list, err := ParseRequests(contents) | |
| 367 | if err != nil { | |
| 368 | return 0, fmt.Errorf("requests: cannot follow %s: %w", branch, err) | |
| 369 | } | |
| 370 | updated := 0 | |
| 371 | for _, r := range list { | |
| 372 | if r.State != "open" || r.HeadRef != branch || r.Head == head { | |
| 373 | continue | |
| 374 | } | |
| 375 | r.Head = head | |
| 376 | raw, err := renderRequest(r) | |
| 377 | if err != nil { | |
| 378 | return 0, err | |
| 379 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
380 | files[fmt.Sprintf("%s/%s/%s", ChangesDir, r.ID, changeName)] = raw |
|
e535ca55
Keenan Nemetz
4d ago
|
381 | updated++ |
| 382 | } | |
| 383 | return updated, nil | |
| 384 | } | |
| 385 | ||
|
1eeca4e6
Keenan Nemetz
17d ago
|
386 | // Approve records principal's approval of the request's current head and base. |
|
e050e14a
Keenan Nemetz
13d ago
|
387 | // |
| 388 | // expectedBase and expectedHead are the target the approver was looking at. If a | |
| 389 | // push moved the followed branch, or the request was retargeted at another base, | |
| 390 | // the approval is refused rather than silently transferred to a target nobody | |
| 391 | // reviewed, and the caller reads the request again. | |
| 392 | func ApproveRequest(g *git.Repo, id, principal, expectedBase, expectedHead string, now time.Time, actor string) (*Request, error) { | |
|
62576fa8
Keenan Nemetz
14d ago
|
393 | if !assigneeRE.MatchString(principal) { |
| 394 | return nil, fmt.Errorf("requests: %q is not an account name", principal) | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
395 | } |
| 396 | var out *Request | |
|
b492b557
Keenan Nemetz
3d ago
|
397 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
|
c5902451
Keenan Nemetz
16d ago
|
398 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
|
1eeca4e6
Keenan Nemetz
17d ago
|
399 | r, err := loadFrom(files, id) |
| 400 | if err != nil { | |
| 401 | return err | |
| 402 | } | |
|
e050e14a
Keenan Nemetz
13d ago
|
403 | if r.State != "open" { |
| 404 | return fmt.Errorf("requests: %s is %s, so it cannot be approved", id, r.State) | |
| 405 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
406 | if r.Author == principal { |
|
31df8bae
Keenan Nemetz
17d ago
|
407 | return fmt.Errorf("requests: the author cannot approve their own request") |
|
1eeca4e6
Keenan Nemetz
17d ago
|
408 | } |
| 409 | if r.Head == "" { | |
| 410 | return fmt.Errorf("requests: %s has no head commit to approve", id) | |
| 411 | } | |
|
e050e14a
Keenan Nemetz
13d ago
|
412 | if expectedBase != "" && r.Base != expectedBase { |
| 413 | return fmt.Errorf( | |
| 414 | "requests: %s moved base from %s to %s before the approval was written; review the new base and approve again", | |
| 415 | id, expectedBase, r.Base) | |
| 416 | } | |
| 417 | if expectedHead != "" && r.Head != expectedHead { | |
| 418 | return fmt.Errorf( | |
| 419 | "requests: %s moved from %s to %s before the approval was written; review the new head and approve again", | |
| 420 | id, format.SHA(expectedHead), format.SHA(r.Head)) | |
| 421 | } | |
|
307cbc2a
Keenan Nemetz
17d ago
|
422 | raw, err := renderApproval(Approval{ |
| 423 | Principal: principal, | |
| 424 | Base: r.Base, | |
| 425 | Head: r.Head, | |
| 426 | Created: now.UTC(), | |
| 427 | }) | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
428 | if err != nil { |
| 429 | return err | |
| 430 | } | |
|
307cbc2a
Keenan Nemetz
17d ago
|
431 | files[fmt.Sprintf("%s%s/%s.md", base, approvalDir, principal)] = raw |
|
1eeca4e6
Keenan Nemetz
17d ago
|
432 | out = r |
| 433 | return nil | |
| 434 | }, fmt.Sprintf("approve: %s by %s", id, principal), actor) | |
| 435 | return out, err | |
| 436 | } | |
| 437 | ||
| 438 | // SetState moves a request between open and closed. | |
|
c5902451
Keenan Nemetz
16d ago
|
439 | func SetRequestState(g *git.Repo, id, state, actor string) (*Request, error) { |
|
38b30251
Keenan Nemetz
13d ago
|
440 | // "merged" is not a state a caller may set: it is the outcome of a landing, and |
| 441 | // it has to travel with the commit that landed. Use MarkMerged, which writes the | |
| 442 | // state and the commit together, so a request cannot claim a merge no commit | |
| 443 | // backs. | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
444 | switch state { |
|
38b30251
Keenan Nemetz
13d ago
|
445 | case "open", "closed": |
|
1eeca4e6
Keenan Nemetz
17d ago
|
446 | default: |
| 447 | return nil, fmt.Errorf("requests: state must be open or closed") | |
| 448 | } | |
| 449 | var out *Request | |
|
b492b557
Keenan Nemetz
3d ago
|
450 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
|
c5902451
Keenan Nemetz
16d ago
|
451 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
|
1eeca4e6
Keenan Nemetz
17d ago
|
452 | r, err := loadFrom(files, id) |
| 453 | if err != nil { | |
| 454 | return err | |
| 455 | } | |
| 456 | r.State = state | |
|
307cbc2a
Keenan Nemetz
17d ago
|
457 | raw, err := renderRequest(r) |
|
1eeca4e6
Keenan Nemetz
17d ago
|
458 | if err != nil { |
| 459 | return err | |
| 460 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
461 | files[base+changeName] = raw |
|
1eeca4e6
Keenan Nemetz
17d ago
|
462 | out = r |
| 463 | return nil | |
| 464 | }, fmt.Sprintf("request: %s %s", id, state), actor) | |
| 465 | return out, err | |
| 466 | } | |
| 467 | ||
|
38b30251
Keenan Nemetz
13d ago
|
468 | // MarkMerged records a landing: the state becomes merged and the request names the |
|
2841d922
Keenan Nemetz
13d ago
|
469 | // commit that landed, the base tip it landed on, who landed it, and when. The mark is |
| 470 | // written in the same commit as the state, so the file cannot say "merged" and mean | |
| 471 | // nothing. | |
| 472 | // | |
| 473 | // landedBase is what the base branch pointed at before the landing. It is what the | |
| 474 | // Files tab diffs a merged request against, because the head is in the base | |
| 475 | // afterwards and a diff against the base as it is now says nothing. A caller that | |
| 476 | // does not know it — a retry that finds the head already landed has no base tip of | |
| 477 | // its own to name — passes "", which leaves whatever the record already says. | |
|
38b30251
Keenan Nemetz
13d ago
|
478 | // |
|
e050e14a
Keenan Nemetz
13d ago
|
479 | // reviewedHead is the commit the approvals named. It is written to head as the |
| 480 | // record is marked merged, because a followed branch may have moved after the | |
| 481 | // landing was prepared and the merged request must describe what actually landed. | |
| 482 | // | |
|
38b30251
Keenan Nemetz
13d ago
|
483 | // A request that is already merged with the same commit is left alone and returned |
| 484 | // unchanged, which makes a retry after a half-finished landing harmless. | |
|
e050e14a
Keenan Nemetz
13d ago
|
485 | func MarkMerged(g *git.Repo, id, landedBase, landedHead, reviewedHead, actor string, at time.Time) (*Request, []string, error) { |
|
38b30251
Keenan Nemetz
13d ago
|
486 | if !shaRE.MatchString(landedHead) { |
|
183d48e5
Keenan Nemetz
13d ago
|
487 | return nil, nil, fmt.Errorf("requests: merged head must be a full commit id, got %q", landedHead) |
|
38b30251
Keenan Nemetz
13d ago
|
488 | } |
|
2841d922
Keenan Nemetz
13d ago
|
489 | if landedBase != "" && !shaRE.MatchString(landedBase) { |
| 490 | return nil, nil, fmt.Errorf("requests: merged base must be a full commit id, got %q", landedBase) | |
| 491 | } | |
|
e050e14a
Keenan Nemetz
13d ago
|
492 | if reviewedHead != "" && !shaRE.MatchString(reviewedHead) { |
| 493 | return nil, nil, fmt.Errorf("requests: reviewed head must be a full commit id, got %q", reviewedHead) | |
| 494 | } | |
|
38b30251
Keenan Nemetz
13d ago
|
495 | var ( |
|
183d48e5
Keenan Nemetz
13d ago
|
496 | out *Request |
| 497 | closed []string | |
|
38b30251
Keenan Nemetz
13d ago
|
498 | ) |
|
b492b557
Keenan Nemetz
3d ago
|
499 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
|
38b30251
Keenan Nemetz
13d ago
|
500 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 501 | r, err := loadFrom(files, id) | |
| 502 | if err != nil { | |
| 503 | return err | |
| 504 | } | |
|
2841d922
Keenan Nemetz
13d ago
|
505 | if landedBase == "" { |
| 506 | landedBase = r.MergedBase // a retry that cannot name the base keeps the one it has | |
| 507 | } | |
| 508 | if r.State == "merged" && r.MergedHead == landedHead && r.MergedBase == landedBase { | |
|
38b30251
Keenan Nemetz
13d ago
|
509 | out = r |
| 510 | return ErrNoChange | |
| 511 | } | |
| 512 | r.State = "merged" | |
|
e050e14a
Keenan Nemetz
13d ago
|
513 | // Freeze the commit the review named. The branch may have moved after the |
| 514 | // landing was prepared; the merged record has to describe what landed, not | |
| 515 | // where the branch went next. | |
| 516 | if reviewedHead != "" { | |
| 517 | r.Head = reviewedHead | |
| 518 | } | |
|
2841d922
Keenan Nemetz
13d ago
|
519 | r.MergedBase = landedBase |
|
38b30251
Keenan Nemetz
13d ago
|
520 | r.MergedHead = landedHead |
| 521 | r.MergedBy = actor | |
| 522 | r.MergedAt = at.UTC() | |
| 523 | raw, err := renderRequest(r) | |
| 524 | if err != nil { | |
| 525 | return err | |
| 526 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
527 | files[base+changeName] = raw |
|
183d48e5
Keenan Nemetz
13d ago
|
528 | // The issues this request promised to close are closed in the same commit as |
| 529 | // the mark, so the merge and the closure cannot be separated by a crash. An | |
| 530 | // issue that is already closed, or that is no longer there, is left alone: the | |
| 531 | // promise is about what the merge did, not about overriding a later decision. | |
| 532 | ids, err := closeIssues(files, r.Closes) | |
| 533 | if err != nil { | |
| 534 | return err | |
| 535 | } | |
| 536 | closed = ids | |
|
e535ca55
Keenan Nemetz
4d ago
|
537 | // The landing moved r.Base, so the requests that follow that branch move |
| 538 | // with it. This is the rule post-receive applies to a push, applied to the | |
| 539 | // merge that was not a push: without it a stacked request keeps reviewing | |
| 540 | // the pre-merge tip. Same commit as the mark, so the two cannot disagree. | |
| 541 | if _, err := followBranch(files, r.Base, landedHead); err != nil { | |
| 542 | return err | |
| 543 | } | |
|
38b30251
Keenan Nemetz
13d ago
|
544 | out = r |
| 545 | return nil | |
| 546 | }, fmt.Sprintf("request: %s merged %s", id, landedHead), actor) | |
| 547 | if errors.Is(err, ErrNoChange) { | |
|
183d48e5
Keenan Nemetz
13d ago
|
548 | return out, nil, nil |
| 549 | } | |
| 550 | if err != nil { | |
| 551 | return nil, nil, err | |
| 552 | } | |
| 553 | return out, closed, nil | |
| 554 | } | |
| 555 | ||
| 556 | // closeIssues rewrites the named issues as closed inside an open metadata commit, | |
| 557 | // and returns the ones it actually changed. Unknown and already-closed issues are | |
| 558 | // skipped: a request may outlive the issue it was filed against, and a merge must not | |
| 559 | // reopen-and-close something somebody closed on purpose. | |
| 560 | func closeIssues(files map[string][]byte, ids []string) ([]string, error) { | |
| 561 | if len(ids) == 0 { | |
| 562 | return nil, nil | |
| 563 | } | |
| 564 | contents := make(map[string]string, len(files)) | |
| 565 | for path, raw := range files { | |
| 566 | if strings.HasPrefix(path, IssuesDir+"/") { | |
| 567 | contents[path] = string(raw) | |
| 568 | } | |
|
38b30251
Keenan Nemetz
13d ago
|
569 | } |
|
183d48e5
Keenan Nemetz
13d ago
|
570 | issues, err := ParseIssues(contents) |
|
38b30251
Keenan Nemetz
13d ago
|
571 | if err != nil { |
|
183d48e5
Keenan Nemetz
13d ago
|
572 | return nil, fmt.Errorf("requests: cannot read the issues this request closes: %w", err) |
| 573 | } | |
| 574 | byID := make(map[string]*Issue, len(issues)) | |
| 575 | for _, iss := range issues { | |
| 576 | byID[iss.ID] = iss | |
| 577 | } | |
| 578 | var closed []string | |
| 579 | for _, id := range ids { | |
| 580 | iss, ok := byID[id] | |
| 581 | if !ok || iss.State == "closed" { | |
| 582 | continue | |
| 583 | } | |
| 584 | iss.State = "closed" | |
| 585 | raw, err := renderIssue(iss) | |
| 586 | if err != nil { | |
| 587 | return nil, err | |
| 588 | } | |
| 589 | files[fmt.Sprintf("%s/%s/%s", IssuesDir, id, issueName)] = raw | |
| 590 | closed = append(closed, id) | |
|
38b30251
Keenan Nemetz
13d ago
|
591 | } |
|
183d48e5
Keenan Nemetz
13d ago
|
592 | return closed, nil |
|
38b30251
Keenan Nemetz
13d ago
|
593 | } |
| 594 | ||
|
7c6e52e7
Keenan Nemetz
17d ago
|
595 | // NewComment builds a comment on a request with a fresh id. |
|
c5902451
Keenan Nemetz
16d ago
|
596 | func NewRequestComment(body, author string, now time.Time) (*Comment, error) { |
| 597 | commentID, err := voleid.New(now) | |
|
7c6e52e7
Keenan Nemetz
17d ago
|
598 | if err != nil { |
| 599 | return nil, err | |
| 600 | } | |
| 601 | c := &Comment{ID: commentID, Author: strings.TrimSpace(author), Created: now.UTC(), Body: body} | |
| 602 | if err := validateComment(c); err != nil { | |
| 603 | return nil, err | |
| 604 | } | |
| 605 | return c, nil | |
| 606 | } | |
| 607 | ||
| 608 | func validateComment(c *Comment) error { | |
| 609 | if strings.TrimSpace(c.Body) == "" { | |
| 610 | return fmt.Errorf("requests: a comment needs a body") | |
| 611 | } | |
|
4581690d
Keenan Nemetz
13d ago
|
612 | if problems := bodyProblems("comment", c.Body); len(problems) > 0 { |
| 613 | return fmt.Errorf("requests: %s", problems[0]) | |
| 614 | } | |
|
7c6e52e7
Keenan Nemetz
17d ago
|
615 | if strings.TrimSpace(c.Author) == "" { |
| 616 | return fmt.Errorf("requests: a comment needs an author") | |
| 617 | } | |
| 618 | return nil | |
| 619 | } | |
| 620 | ||
| 621 | // AddComment appends a comment to a request. It is the same file shape a comment | |
| 622 | // on an issue has, written by the same code, so review discussion needs no format | |
| 623 | // of its own. | |
|
c5902451
Keenan Nemetz
16d ago
|
624 | func AddRequestComment(g *git.Repo, requestID string, c *Comment, actor string) error { |
|
7c6e52e7
Keenan Nemetz
17d ago
|
625 | if strings.TrimSpace(c.Author) == "" { |
| 626 | c.Author = actor | |
| 627 | } | |
| 628 | if c.Created.IsZero() { | |
| 629 | c.Created = time.Now().UTC() | |
| 630 | } | |
| 631 | if err := validateComment(c); err != nil { | |
| 632 | return err | |
| 633 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
634 | raw, err := RenderComment(c) |
|
7c6e52e7
Keenan Nemetz
17d ago
|
635 | if err != nil { |
| 636 | return err | |
| 637 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
638 | path := RequestCommentPath(requestID, c.ID) |
| 639 | _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error { | |
|
f6f4b1db
Keenan Nemetz
15d ago
|
640 | r, err := loadFrom(files, requestID) |
| 641 | if err != nil { | |
|
7c6e52e7
Keenan Nemetz
17d ago
|
642 | return err |
| 643 | } | |
|
f6f4b1db
Keenan Nemetz
15d ago
|
644 | if prob := checkCommentReply(r.Comments, c); prob != "" { |
| 645 | return fmt.Errorf("requests: %s", prob) | |
| 646 | } | |
|
7c6e52e7
Keenan Nemetz
17d ago
|
647 | if _, exists := files[path]; exists { |
| 648 | return fmt.Errorf("requests: comment %s already exists", c.ID) | |
| 649 | } | |
| 650 | files[path] = raw | |
| 651 | return nil | |
| 652 | }, fmt.Sprintf("request: comment on %s", requestID), c.Author) | |
| 653 | return err | |
| 654 | } | |
| 655 | ||
| 656 | // EditComment rewrites a comment's body in place. The path stays, so the comment | |
| 657 | // keeps its id and one `git log -p -- <path>` answers what it used to say; there | |
| 658 | // is no `edited` field, for the reason nothing else in this format has one. | |
|
c5902451
Keenan Nemetz
16d ago
|
659 | func EditRequestComment(g *git.Repo, requestID, commentID, body, actor string) (*Comment, error) { |
|
122cbe4f
Keenan Nemetz
7d ago
|
660 | body = strings.TrimRight(body, "\n") |
| 661 | var out *Comment | |
| 662 | path := RequestCommentPath(requestID, commentID) | |
| 663 | _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) { | |
| 664 | r, err := loadFrom(files, requestID) | |
| 665 | if err != nil { | |
| 666 | return "", err | |
| 667 | } | |
| 668 | c, ok := commentOf(r.Comments, commentID) | |
| 669 | if !ok { | |
| 670 | return "", fmt.Errorf("requests: no comment %s on %s", commentID, requestID) | |
| 671 | } | |
| 672 | if c.Deleted { | |
| 673 | return "", fmt.Errorf("requests: cannot edit a deleted comment") | |
| 674 | } | |
| 675 | c.Body = body | |
| 676 | if err := validateComment(c); err != nil { | |
| 677 | return "", err | |
| 678 | } | |
| 679 | raw, err := RenderComment(c) | |
| 680 | if err != nil { | |
| 681 | return "", err | |
|
7c6e52e7
Keenan Nemetz
17d ago
|
682 | } |
| 683 | files[path] = raw | |
|
122cbe4f
Keenan Nemetz
7d ago
|
684 | out = c |
| 685 | return actorOr(actor, c.Author), nil | |
| 686 | }, fmt.Sprintf("request: edit comment %s on %s", commentID, requestID)) | |
|
7c6e52e7
Keenan Nemetz
17d ago
|
687 | if err != nil { |
| 688 | return nil, err | |
| 689 | } | |
|
122cbe4f
Keenan Nemetz
7d ago
|
690 | return out, nil |
|
7c6e52e7
Keenan Nemetz
17d ago
|
691 | } |
| 692 | ||
|
f6f4b1db
Keenan Nemetz
15d ago
|
693 | // DeleteComment replaces a comment with a tombstone. The text stays in history: |
| 694 | // deleting is not redaction, which is an admin force-push. Replies keep the | |
| 695 | // parent they answer. | |
|
c5902451
Keenan Nemetz
16d ago
|
696 | func DeleteRequestComment(g *git.Repo, requestID, commentID, actor string) (*Comment, error) { |
|
122cbe4f
Keenan Nemetz
7d ago
|
697 | var out *Comment |
| 698 | path := RequestCommentPath(requestID, commentID) | |
| 699 | _, err := git.CommitFilesBy(g, Ref, func(files map[string][]byte) (string, error) { | |
| 700 | r, err := loadFrom(files, requestID) | |
| 701 | if err != nil { | |
| 702 | return "", err | |
| 703 | } | |
| 704 | c, ok := commentOf(r.Comments, commentID) | |
| 705 | if !ok { | |
| 706 | return "", fmt.Errorf("requests: no comment %s on %s", commentID, requestID) | |
| 707 | } | |
| 708 | if c.Deleted { | |
| 709 | return "", fmt.Errorf("requests: comment %s is already deleted", commentID) | |
| 710 | } | |
| 711 | c.Deleted = true | |
| 712 | c.Body = "" | |
| 713 | c.Anchor = nil | |
| 714 | raw, err := RenderComment(c) | |
| 715 | if err != nil { | |
| 716 | return "", err | |
|
7c6e52e7
Keenan Nemetz
17d ago
|
717 | } |
|
f6f4b1db
Keenan Nemetz
15d ago
|
718 | files[path] = raw |
|
122cbe4f
Keenan Nemetz
7d ago
|
719 | out = c |
| 720 | return actorOr(actor, c.Author), nil | |
| 721 | }, fmt.Sprintf("request: delete comment %s on %s", commentID, requestID)) | |
|
7c6e52e7
Keenan Nemetz
17d ago
|
722 | if err != nil { |
| 723 | return nil, err | |
| 724 | } | |
|
122cbe4f
Keenan Nemetz
7d ago
|
725 | return out, nil |
|
7c6e52e7
Keenan Nemetz
17d ago
|
726 | } |
| 727 | ||
|
1eeca4e6
Keenan Nemetz
17d ago
|
728 | // loadFrom reads a request out of the tree map a commit is being built from, so |
| 729 | // mutations always see the tip they are about to modify. | |
| 730 | func loadFrom(files map[string][]byte, id string) (*Request, error) { | |
| 731 | contents := make(map[string]string, len(files)) | |
| 732 | for k, v := range files { | |
| 733 | contents[k] = string(v) | |
| 734 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
735 | list, err := ParseRequests(contents) |
|
1eeca4e6
Keenan Nemetz
17d ago
|
736 | if err != nil { |
| 737 | if ve, ok := err.(*ValidationError); ok { | |
| 738 | return nil, fmt.Errorf("requests: the metadata tree is invalid: %s", ve.Problems[0]) | |
| 739 | } | |
| 740 | return nil, err | |
| 741 | } | |
| 742 | for _, r := range list { | |
| 743 | if r.ID == id { | |
| 744 | return r, nil | |
| 745 | } | |
| 746 | } | |
| 747 | return nil, fmt.Errorf("requests: no request %s", id) | |
| 748 | } | |
|
bdf6a0de
Keenan Nemetz
13d ago
|
749 | |
| 750 | // SetRequestCloses replaces the issues a request promises to close. An open request is | |
| 751 | // the only thing that can land, so it is the only thing whose links are worth | |
| 752 | // changing: a merged request's list is a record of what the merge did, and a closed | |
| 753 | // one is not going to run. | |
| 754 | // | |
| 755 | // The ids are checked against the issues tree here as well as at creation, because a | |
| 756 | // request may be linked long after it was opened, and a typo at that point would | |
| 757 | // otherwise surface as an issue that quietly never closes. | |
| 758 | func SetRequestCloses(g *git.Repo, id string, closes []string, actor string) (*Request, error) { | |
| 759 | closes, problems := parseCloses(closes, "request") | |
| 760 | if len(problems) > 0 { | |
| 761 | return nil, fmt.Errorf("%s", strings.Join(problems, "; ")) | |
| 762 | } | |
| 763 | var out *Request | |
|
b492b557
Keenan Nemetz
3d ago
|
764 | base := fmt.Sprintf("%s/%s/", ChangesDir, id) |
|
bdf6a0de
Keenan Nemetz
13d ago
|
765 | _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { |
| 766 | r, err := loadFrom(files, id) | |
| 767 | if err != nil { | |
| 768 | return err | |
| 769 | } | |
| 770 | if r.State != "open" { | |
| 771 | return fmt.Errorf("requests: %s is %s, so its links cannot change", id, r.State) | |
| 772 | } | |
| 773 | if equalStrings(r.Closes, closes) { | |
| 774 | out = r | |
| 775 | return ErrNoChange | |
| 776 | } | |
|
122cbe4f
Keenan Nemetz
7d ago
|
777 | // The ids are checked against the issues tree this commit replaces, because a |
| 778 | // request may be linked long after it was opened, and a typo at that point | |
| 779 | // would otherwise surface as an issue that quietly never closes. | |
| 780 | if err := issuesExist(files, closes); err != nil { | |
| 781 | return err | |
| 782 | } | |
|
bdf6a0de
Keenan Nemetz
13d ago
|
783 | r.Closes = closes |
| 784 | raw, err := renderRequest(r) | |
| 785 | if err != nil { | |
| 786 | return err | |
| 787 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
788 | files[base+changeName] = raw |
|
bdf6a0de
Keenan Nemetz
13d ago
|
789 | out = r |
| 790 | return nil | |
| 791 | }, fmt.Sprintf("request: %s closes %d issue(s)", id, len(closes)), actor) | |
| 792 | if errors.Is(err, ErrNoChange) { | |
| 793 | return out, nil | |
| 794 | } | |
| 795 | return out, err | |
| 796 | } | |
| 797 | ||
|
122cbe4f
Keenan Nemetz
7d ago
|
798 | // issuesExist reports whether every issue a request promises to close is on the |
| 799 | // branch the request is being written to. It reads the map the commit is built from, | |
| 800 | // so the promise is checked against the issues the writer is about to keep. | |
| 801 | func issuesExist(files map[string][]byte, closes []string) error { | |
| 802 | if len(closes) == 0 { | |
| 803 | return nil | |
| 804 | } | |
| 805 | known := make(map[string]bool, len(files)) | |
| 806 | for path := range files { | |
| 807 | if id, ok := IssueIDFromPath(path); ok && !IsIssueCommentPath(path) { | |
| 808 | known[id] = true | |
| 809 | } | |
| 810 | } | |
| 811 | for _, id := range closes { | |
| 812 | if !known[id] { | |
| 813 | return fmt.Errorf("requests: closes %s, and there is no such issue", id) | |
| 814 | } | |
| 815 | } | |
| 816 | return nil | |
| 817 | } | |
| 818 | ||
|
bdf6a0de
Keenan Nemetz
13d ago
|
819 | func equalStrings(a, b []string) bool { |
| 820 | if len(a) != len(b) { | |
| 821 | return false | |
| 822 | } | |
| 823 | for i := range a { | |
| 824 | if a[i] != b[i] { | |
| 825 | return false | |
| 826 | } | |
| 827 | } | |
| 828 | return true | |
| 829 | } |