1package resolvers
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "math"
9 "sort"
10 "time"
11
12 "github.com/git-bug/git-bug/api/auth"
13 "github.com/git-bug/git-bug/api/graphql/connections"
14 "github.com/git-bug/git-bug/api/graphql/graph"
15 "github.com/git-bug/git-bug/api/graphql/models"
16 "github.com/git-bug/git-bug/entities/common"
17 "github.com/git-bug/git-bug/entity"
18 "github.com/git-bug/git-bug/query"
19 "github.com/git-bug/git-bug/repository"
20)
21
22var _ graph.RepositoryResolver = &repoResolver{}
23
24type repoResolver struct{}
25
26func (repoResolver) Name(_ context.Context, obj *models.Repository) (*string, error) {
27 if obj.Repo.IsDefaultRepo() {
28 return nil, nil
29 }
30 name := obj.Repo.Name()
31 return &name, nil
32}
33
34func (repoResolver) AllBugs(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, queryStr *string) (*models.BugConnection, error) {
35 input := models.ConnectionInput{
36 Before: before,
37 After: after,
38 First: first,
39 Last: last,
40 }
41
42 var q *query.Query
43 if queryStr != nil {
44 query2, err := query.Parse(*queryStr)
45 if err != nil {
46 return nil, err
47 }
48 q = query2
49 } else {
50 q = query.NewQuery()
51 }
52
53 // Simply pass a []string with the ids to the pagination algorithm
54 source, err := obj.Repo.Bugs().Query(q)
55 if err != nil {
56 return nil, err
57 }
58
59 // The edger create a custom edge holding just the id
60 edger := func(id entity.Id, offset int) connections.Edge {
61 return connections.LazyBugEdge{
62 Id: id,
63 Cursor: connections.OffsetToCursor(offset),
64 }
65 }
66
67 // The conMaker will finally load and compile bugs from git to replace the selected edges
68 conMaker := func(lazyBugEdges []*connections.LazyBugEdge, lazyNode []entity.Id, info *models.PageInfo, totalCount int) (*models.BugConnection, error) {
69 edges := make([]*models.BugEdge, len(lazyBugEdges))
70 nodes := make([]models.BugWrapper, len(lazyBugEdges))
71
72 for i, lazyBugEdge := range lazyBugEdges {
73 excerpt, err := obj.Repo.Bugs().ResolveExcerpt(lazyBugEdge.Id)
74 if err != nil {
75 return nil, err
76 }
77
78 b := models.NewLazyBug(obj.Repo, excerpt)
79
80 edges[i] = &models.BugEdge{
81 Cursor: lazyBugEdge.Cursor,
82 Node: b,
83 }
84 nodes[i] = b
85 }
86
87 return &models.BugConnection{
88 Edges: edges,
89 Nodes: nodes,
90 PageInfo: info,
91 TotalCount: totalCount,
92 }, nil
93 }
94
95 return connections.Connection(source, edger, conMaker, input)
96}
97
98func (repoResolver) Bug(_ context.Context, obj *models.Repository, prefix string) (models.BugWrapper, error) {
99 excerpt, err := obj.Repo.Bugs().ResolveExcerptPrefix(prefix)
100 if entity.IsErrNotFound(err) {
101 return nil, nil
102 }
103 if err != nil {
104 return nil, err
105 }
106
107 return models.NewLazyBug(obj.Repo, excerpt), nil
108}
109
110func (repoResolver) AllIdentities(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.IdentityConnection, error) {
111 input := models.ConnectionInput{
112 Before: before,
113 After: after,
114 First: first,
115 Last: last,
116 }
117
118 // Simply pass a []string with the ids to the pagination algorithm
119 source := obj.Repo.Identities().AllIds()
120
121 // The edger create a custom edge holding just the id
122 edger := func(id entity.Id, offset int) connections.Edge {
123 return connections.LazyIdentityEdge{
124 Id: id,
125 Cursor: connections.OffsetToCursor(offset),
126 }
127 }
128
129 // The conMaker will finally load and compile identities from git to replace the selected edges
130 conMaker := func(lazyIdentityEdges []*connections.LazyIdentityEdge, lazyNode []entity.Id, info *models.PageInfo, totalCount int) (*models.IdentityConnection, error) {
131 edges := make([]*models.IdentityEdge, len(lazyIdentityEdges))
132 nodes := make([]models.IdentityWrapper, len(lazyIdentityEdges))
133
134 for k, lazyIdentityEdge := range lazyIdentityEdges {
135 excerpt, err := obj.Repo.Identities().ResolveExcerpt(lazyIdentityEdge.Id)
136 if err != nil {
137 return nil, err
138 }
139
140 i := models.NewLazyIdentity(obj.Repo, excerpt)
141
142 edges[k] = &models.IdentityEdge{
143 Cursor: lazyIdentityEdge.Cursor,
144 Node: i,
145 }
146 nodes[k] = i
147 }
148
149 return &models.IdentityConnection{
150 Edges: edges,
151 Nodes: nodes,
152 PageInfo: info,
153 TotalCount: totalCount,
154 }, nil
155 }
156
157 return connections.Connection(source, edger, conMaker, input)
158}
159
160func (repoResolver) Identity(_ context.Context, obj *models.Repository, prefix string) (models.IdentityWrapper, error) {
161 excerpt, err := obj.Repo.Identities().ResolveExcerptPrefix(prefix)
162 if entity.IsErrNotFound(err) {
163 return nil, nil
164 }
165 if err != nil {
166 return nil, err
167 }
168
169 return models.NewLazyIdentity(obj.Repo, excerpt), nil
170}
171
172func (repoResolver) UserIdentity(ctx context.Context, obj *models.Repository) (models.IdentityWrapper, error) {
173 id, err := auth.UserFromCtx(ctx, obj.Repo)
174 if err == auth.ErrNotAuthenticated {
175 return nil, nil
176 } else if err != nil {
177 return nil, err
178 }
179 return models.NewLoadedIdentity(id.Identity), nil
180}
181
182func (repoResolver) ValidLabels(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.LabelConnection, error) {
183 input := models.ConnectionInput{
184 Before: before,
185 After: after,
186 First: first,
187 Last: last,
188 }
189
190 edger := func(label common.Label, offset int) connections.Edge {
191 return models.LabelEdge{
192 Node: label,
193 Cursor: connections.OffsetToCursor(offset),
194 }
195 }
196
197 conMaker := func(edges []*models.LabelEdge, nodes []common.Label, info *models.PageInfo, totalCount int) (*models.LabelConnection, error) {
198 return &models.LabelConnection{
199 Edges: edges,
200 Nodes: nodes,
201 PageInfo: info,
202 TotalCount: totalCount,
203 }, nil
204 }
205
206 return connections.Connection(obj.Repo.Bugs().ValidLabels(), edger, conMaker, input)
207}
208
209func (repoResolver) Refs(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, typeArg *models.GitRefType) (*models.GitRefConnection, error) {
210 repo := obj.Repo.BrowseRepo()
211
212 var refs []*models.GitRef
213
214 if typeArg == nil || *typeArg == models.GitRefTypeBranch {
215 branches, err := repo.Branches()
216 if err != nil {
217 return nil, err
218 }
219 for _, b := range branches {
220 refs = append(refs, &models.GitRef{
221 Name: "refs/heads/" + b.Name,
222 ShortName: b.Name,
223 Type: models.GitRefTypeBranch,
224 Hash: string(b.Hash),
225 IsDefault: b.IsDefault,
226 })
227 }
228 }
229
230 if typeArg == nil || *typeArg == models.GitRefTypeTag {
231 tags, err := repo.Tags()
232 if err != nil {
233 return nil, err
234 }
235 for _, t := range tags {
236 refs = append(refs, &models.GitRef{
237 Name: "refs/tags/" + t.Name,
238 ShortName: t.Name,
239 Type: models.GitRefTypeTag,
240 Hash: string(t.Hash),
241 })
242 }
243 }
244
245 // Sort by type (branches before tags) then by short name for stable cursors.
246 sort.Slice(refs, func(i, j int) bool {
247 if refs[i].Type != refs[j].Type {
248 return refs[i].Type < refs[j].Type
249 }
250 return refs[i].ShortName < refs[j].ShortName
251 })
252
253 input := models.ConnectionInput{After: after, Before: before, First: first, Last: last}
254 edger := func(r *models.GitRef, offset int) connections.Edge {
255 return connections.CursorEdge{Cursor: connections.OffsetToCursor(offset)}
256 }
257 conMaker := func(edges []*connections.CursorEdge, nodes []*models.GitRef, info *models.PageInfo, total int) (*models.GitRefConnection, error) {
258 return &models.GitRefConnection{Nodes: nodes, PageInfo: info, TotalCount: total}, nil
259 }
260 return connections.Connection(refs, edger, conMaker, input)
261}
262
263func (repoResolver) Tree(_ context.Context, obj *models.Repository, ref string, path *string) ([]*repository.TreeEntry, error) {
264 repo := obj.Repo.BrowseRepo()
265 p := ""
266 if path != nil {
267 p = *path
268 }
269 entries, err := repo.TreeAtPath(ref, p)
270 if err != nil {
271 return nil, err
272 }
273 ptrs := make([]*repository.TreeEntry, len(entries))
274 for i := range entries {
275 ptrs[i] = &entries[i]
276 }
277 return ptrs, nil
278}
279
280func (repoResolver) Blob(_ context.Context, obj *models.Repository, ref string, path string) (*models.GitBlob, error) {
281 repo := obj.Repo.BrowseRepo()
282 rc, size, hash, err := repo.BlobAtPath(ref, path)
283 if errors.Is(err, repository.ErrNotFound) {
284 return nil, nil
285 }
286 if err != nil {
287 return nil, err
288 }
289 defer rc.Close()
290
291 limited := io.LimitReader(rc, blobTruncateSize+1)
292 data, err := io.ReadAll(limited)
293 if err != nil {
294 return nil, err
295 }
296
297 // Binary detection: same heuristic as git — a null byte anywhere in the
298 // content means binary. Git caps its probe at 8000 bytes; we probe all
299 // bytes read (up to blobTruncateSize+1) before slicing, so a NUL in the
300 // extra byte also triggers the flag. Files whose first blobTruncateSize
301 // bytes are all non-NUL will be reported as text even if the remainder is
302 // binary; this is a documented prefix-based heuristic.
303 isBinary := bytes.IndexByte(data, 0) >= 0
304
305 isTruncated := int64(len(data)) > blobTruncateSize
306 if isTruncated {
307 data = data[:blobTruncateSize]
308 }
309
310 blob := &models.GitBlob{
311 Path: path,
312 Hash: string(hash),
313 // GraphQL Int is 32-bit; clamp to avoid overflow on 32-bit platforms or for
314 // exceptionally large files (which will be truncated anyway).
315 Size: int(min(size, int64(math.MaxInt32))),
316 IsBinary: isBinary,
317 IsTruncated: isTruncated,
318 }
319 if !isBinary {
320 text := string(data)
321 blob.Text = &text
322 }
323 return blob, nil
324}
325
326func (repoResolver) Commits(_ context.Context, obj *models.Repository, after *string, first *int, ref string, path *string, since *time.Time, until *time.Time) (*models.GitCommitConnection, error) {
327 // This is not using the normal relay pagination (connection.Connection()), because that requires having the
328 // full list in memory. Here, go-git does a partial walk only, which is better.
329
330 repo := obj.Repo.BrowseRepo()
331
332 p := ""
333 if path != nil {
334 p = *path
335 }
336
337 const defaultFirst = 20
338 const maxFirst = 100
339
340 n := defaultFirst
341 if first != nil {
342 n = *first
343 if n > maxFirst {
344 n = maxFirst
345 }
346 }
347 limit := n + 1 // fetch one extra to detect hasNextPage
348
349 var afterHash repository.Hash
350 if after != nil {
351 afterHash = repository.Hash(*after)
352 }
353
354 commits, err := repo.CommitLog(ref, p, limit, afterHash, since, until)
355 if err != nil {
356 return nil, err
357 }
358
359 hasNextPage := false
360 if len(commits) > n {
361 hasNextPage = true
362 commits = commits[:n]
363 }
364
365 nodes := make([]*models.GitCommitMeta, len(commits))
366 for i := range commits {
367 nodes[i] = &models.GitCommitMeta{Repo: obj.Repo, CommitMeta: commits[i]}
368 }
369
370 startCursor := ""
371 endCursor := ""
372 if len(nodes) > 0 {
373 startCursor = string(nodes[0].Hash)
374 endCursor = string(nodes[len(nodes)-1].Hash)
375 }
376
377 return &models.GitCommitConnection{
378 Nodes: nodes,
379 PageInfo: &models.PageInfo{
380 HasNextPage: hasNextPage,
381 HasPreviousPage: after != nil,
382 StartCursor: startCursor,
383 EndCursor: endCursor,
384 },
385 TotalCount: len(nodes), // lower bound; exact total unknown without full walk
386 }, nil
387}
388
389func (repoResolver) Commit(_ context.Context, obj *models.Repository, hash string) (*models.GitCommitMeta, error) {
390 repo := obj.Repo.BrowseRepo()
391 detail, err := repo.CommitDetail(repository.Hash(hash))
392 if errors.Is(err, repository.ErrNotFound) {
393 return nil, nil
394 }
395 if err != nil {
396 return nil, err
397 }
398 return &models.GitCommitMeta{Repo: obj.Repo, CommitMeta: detail.CommitMeta}, nil
399}
400
401func (repoResolver) LastCommits(_ context.Context, obj *models.Repository, ref string, path *string, names []string) ([]*models.GitLastCommit, error) {
402 repo := obj.Repo.BrowseRepo()
403 p := ""
404 if path != nil {
405 p = *path
406 }
407 byName, err := repo.LastCommitForEntries(ref, p, names)
408 if err != nil {
409 return nil, err
410 }
411 // Iterate over the input names to preserve caller-specified order.
412 result := make([]*models.GitLastCommit, 0, len(names))
413 for _, name := range names {
414 if meta, ok := byName[name]; ok {
415 m := meta
416 result = append(result, &models.GitLastCommit{Name: name, Commit: &models.GitCommitMeta{Repo: obj.Repo, CommitMeta: m}})
417 }
418 }
419 return result, nil
420}