repo.go

  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}