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	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}