1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "net/url"
11 "strconv"
12 "strings"
13
14 "bnema.dev/ermite/internal/domain"
15)
16
17// GET /repos/{owner}/{repo}/pulls/{number}/files, the only REST call that
18// dorny/paths-filter@v3 makes on a pull_request (src/main.ts:181-215, `octokit
19// .rest.pulls.listFiles` with per_page 100 through paginate.iterator; it reads
20// `filename`, `status`, and `previous_filename` for renames, and stops when the
21// Link header has no rel="next"). It needs no GET /pulls/{n}: the number comes
22// from the event payload.
23
24const (
25 // DefaultPullFilesPerPage and MaxPullFilesPerPage are GitHub's page sizes.
26 DefaultPullFilesPerPage = 30
27 MaxPullFilesPerPage = 100
28 // maxPullFiles is GitHub's cap: "responses include a maximum of 3000 files".
29 maxPullFiles = 3000
30)
31
32// ListPullFiles implements ports.GitHubAPIService.
33func (c *CI) ListPullFiles(ctx context.Context, p domain.Principal, owner, name string, number int64, page, perPage int) ([]byte, string, error) {
34 repo, err := c.pullFilesRepo(ctx, p, owner, name)
35 if err != nil {
36 return nil, "", err
37 }
38 if c.cfg.MRs == nil || number < 1 {
39 return nil, "", domain.ErrNotFound
40 }
41 if page < 1 {
42 page = 1
43 }
44 if perPage < 1 {
45 perPage = DefaultPullFilesPerPage
46 }
47 perPage = min(perPage, MaxPullFilesPerPage)
48 mr, err := c.cfg.MRs.Get(ctx, repo.Name, number)
49 if err != nil {
50 return nil, "", err
51 }
52 files, err := c.pullFiles(ctx, repo.Name, mr)
53 if err != nil {
54 return nil, "", err
55 }
56 total := len(files)
57 // Bounds by division: page * perPage overflows for a huge page, which would
58 // make a negative slice index. A page past the end is empty.
59 lo, hi := total, total
60 if page-1 <= total/perPage {
61 lo = min((page-1)*perPage, total)
62 hi = min(lo+perPage, total)
63 }
64 u := c.urls(repo)
65 out := make([]map[string]any, 0, hi-lo)
66 for _, f := range files[lo:hi] {
67 out = append(out, c.pullFileJSON(u, f))
68 }
69 body, err := json.Marshal(out)
70 if err != nil {
71 return nil, "", err
72 }
73 return body, pullFilesLink(u.api+"/repos/"+u.full+"/pulls/"+strconv.FormatInt(number, 10)+"/files", page, perPage, total), nil
74}
75
76// pullFilesRepo is the access rule of the endpoint. The job token of the
77// repository (which has repo:read only with contents: read) is judged by its
78// pull-requests permission: with pull-requests: read it reads the merge requests
79// of its own repository even when contents is none, as on GitHub; without it a
80// private repository answers 403, a public one is read by anyone. A job token
81// never reads another repository's merge requests. Every other principal is
82// judged by its reach on the repository (githubRepo).
83func (c *CI) pullFilesRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) {
84 if _, _, archive := p.ArchiveGrant(); archive {
85 // An archive token downloads one archive and reads nothing else, not even
86 // the merge requests of a public repository.
87 return domain.Repo{}, domain.ErrNotFound
88 }
89 if !p.ViaJobToken() {
90 return c.githubRepo(ctx, p, owner, name)
91 }
92 if !domain.OwnerMatches(c.cfg.Owner, owner) {
93 return domain.Repo{}, domain.ErrNotFound
94 }
95 rn, err := domain.ParseRepoName(strings.ToLower(name))
96 if err != nil {
97 return domain.Repo{}, domain.ErrNotFound
98 }
99 repo, err := lookupRepo(ctx, c.cfg.Meta, rn)
100 if err != nil {
101 return domain.Repo{}, err
102 }
103 switch {
104 case p.JobPermits(domain.PermPullRequests, domain.PermissionRead, rn):
105 return repo, nil
106 case repo.Visibility == domain.VisibilityPublic:
107 return repo, nil
108 case canRead(p, repo):
109 return domain.Repo{}, fmt.Errorf("%w: the token lacks pull-requests: read", domain.ErrForbidden)
110 }
111 return domain.Repo{}, domain.ErrNotFound // another repository's token
112}
113
114// pullFiles lists the files a merge request changes: between the merge base of
115// the target and the head, and the head, as its review shows them (GitHub's
116// three-dot diff). A closed or merged request uses the base and head it stored.
117func (c *CI) pullFiles(ctx context.Context, repo domain.RepoName, mr domain.MergeRequest) ([]domain.FileDiff, error) {
118 head, base := mr.HeadID, mr.BaseID
119 if mr.IsOpen() {
120 var err error
121 if head, err = c.cfg.Git.BranchHead(ctx, repo, mr.Source); err != nil {
122 if errors.Is(err, domain.ErrNotFound) {
123 return nil, nil // the source branch is gone: no files
124 }
125 return nil, err
126 }
127 tip, err := c.cfg.Git.BranchHead(ctx, repo, mr.Target)
128 switch {
129 case errors.Is(err, domain.ErrNotFound):
130 base = domain.ObjectID{}
131 case err != nil:
132 return nil, err
133 default:
134 switch base, err = c.cfg.Git.MergeBase(ctx, repo, tip, head); {
135 case errors.Is(err, domain.ErrNotFound):
136 base = domain.ObjectID{}
137 case err != nil:
138 return nil, err
139 }
140 }
141 }
142 if head.IsZero() {
143 return nil, nil
144 }
145 if ok, err := c.cfg.Git.HasCommit(ctx, repo, head); err != nil || !ok {
146 return nil, err // the reviewed commits are gone
147 }
148 if base.IsZero() {
149 return nil, nil
150 }
151 // The diff between two commits never changes: the pages of one request (and
152 // the requests that follow while it is not pushed) share one computation.
153 key := pullFilesKey{repo: repo, base: base, head: head}
154 if files, ok := c.pullFileLists.get(key); ok {
155 return files, nil
156 }
157 limits := domain.DiffLimits{MaxFiles: maxPullFiles, MaxFileLines: 2000, MaxTotalLines: 50000, MaxTotalBytes: 8 << 20}
158 _, diff, err := c.cfg.Git.DiffCommits(ctx, repo, base, head, limits)
159 if err != nil {
160 return nil, err
161 }
162 c.pullFileLists.put(key, diff.Files)
163 return diff.Files, nil
164}
165
166// pullFileJSON is one element of the answer, in GitHub's schema (the "diff-entry"
167// of the REST description).
168func (c *CI) pullFileJSON(u ghURLs, f domain.FileDiff) map[string]any {
169 name, status := f.NewPath, "modified"
170 switch f.Status {
171 case domain.FileAdded:
172 status = "added"
173 case domain.FileDeleted:
174 name, status = f.OldPath, "removed"
175 case domain.FileRenamed:
176 status = "renamed"
177 case domain.FileCopied:
178 status = "copied"
179 case domain.FileTypeChanged:
180 status = "changed"
181 }
182 sha := f.NewID.String()
183 if f.NewID.IsZero() {
184 sha = f.OldID.String()
185 }
186 esc := escapeURLPath(name)
187 o := map[string]any{
188 "sha": sha, "filename": name, "status": status,
189 "additions": f.Additions, "deletions": f.Deletions, "changes": f.Additions + f.Deletions,
190 "blob_url": u.repo + "/blob/" + sha + "/" + esc,
191 "raw_url": u.repo + "/raw/" + sha + "/" + esc,
192 "contents_url": u.api + "/repos/" + u.full + "/contents/" + esc,
193 }
194 if f.Status == domain.FileRenamed || f.Status == domain.FileCopied {
195 o["previous_filename"] = f.OldPath
196 }
197 if patch, ok := filePatch(f); ok {
198 o["patch"] = patch
199 }
200 return o
201}
202
203func escapeURLPath(p string) string {
204 segs := strings.Split(p, "/")
205 for i, s := range segs {
206 segs[i] = url.PathEscape(s)
207 }
208 return strings.Join(segs, "/")
209}
210
211// filePatch is the unified-diff hunks of a file, as GitHub's "patch" shows them
212// (hunk headers and lines, no file headers). Absent for a binary file, a pure
213// rename and a file too large for the diff limits, as on GitHub.
214func filePatch(f domain.FileDiff) (string, bool) {
215 if f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 {
216 return "", false
217 }
218 var b strings.Builder
219 for i, h := range f.Hunks {
220 if i > 0 {
221 b.WriteByte('\n')
222 }
223 fmt.Fprintf(&b, "@@ -%s +%s @@", hunkRange(h.OldStart, h.OldLines), hunkRange(h.NewStart, h.NewLines))
224 if h.Header != "" {
225 b.WriteString(" " + h.Header)
226 }
227 for _, l := range h.Lines {
228 b.WriteByte('\n')
229 switch l.Kind {
230 case domain.LineAddition:
231 b.WriteByte('+')
232 case domain.LineDeletion:
233 b.WriteByte('-')
234 default:
235 b.WriteByte(' ')
236 }
237 b.WriteString(l.Text)
238 if l.NoNewlineAtEOF {
239 b.WriteString("\n\\ No newline at end of file")
240 }
241 }
242 }
243 return b.String(), true
244}
245
246func hunkRange(start, n int) string {
247 if n == 1 {
248 return strconv.Itoa(start)
249 }
250 return strconv.Itoa(start) + "," + strconv.Itoa(n)
251}
252
253// pullFilesLink is the Link header of a page: rel next, last, first and prev as
254// GitHub writes them; empty when everything fits on one page.
255func pullFilesLink(base string, page, perPage, total int) string {
256 last := max((total+perPage-1)/perPage, 1)
257 if last == 1 {
258 return ""
259 }
260 at := func(n int) string {
261 return "<" + base + "?per_page=" + strconv.Itoa(perPage) + "&page=" + strconv.Itoa(n) + ">"
262 }
263 var parts []string
264 if page < last {
265 parts = append(parts, at(page+1)+`; rel="next"`, at(last)+`; rel="last"`)
266 }
267 if page > 1 {
268 parts = append(parts, at(1)+`; rel="first"`, at(min(page-1, last))+`; rel="prev"`)
269 }
270 return strings.Join(parts, ", ")
271}