v0.35.1 2aca1df

ci_pullfiles.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close