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