Monorepo for Tangled

knotserver/git: fix pagination in git.Tags

Signed-off-by: oppiliappan <me@oppi.li>

authored by

oppiliappan and committed by tangled.org ef019e79 d21e9ec6

+418 -25
+38 -3
knotserver/git/tag.go
··· 10 10 "github.com/go-git/go-git/v5/plumbing/object" 11 11 ) 12 12 13 - func (g *GitRepo) Tags() ([]object.Tag, error) { 13 + type TagsOptions struct { 14 + Limit int 15 + Offset int 16 + Pattern string 17 + } 18 + 19 + func (g *GitRepo) Tags(opts *TagsOptions) ([]object.Tag, error) { 20 + if opts == nil { 21 + opts = &TagsOptions{} 22 + } 23 + 24 + if opts.Pattern == "" { 25 + opts.Pattern = "refs/tags" 26 + } 27 + 14 28 fields := []string{ 15 29 "refname:short", 16 30 "objectname", ··· 29 43 if i != 0 { 30 44 outFormat.WriteString(fieldSeparator) 31 45 } 32 - outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 46 + fmt.Fprintf(&outFormat, "%%(%s)", f) 33 47 } 34 48 outFormat.WriteString("") 35 49 outFormat.WriteString(recordSeparator) 36 50 37 - output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 51 + args := []string{outFormat.String(), "--sort=-creatordate"} 52 + 53 + // only add the count if the limit is a non-zero value, 54 + // if it is zero, get as many tags as we can 55 + if opts.Limit > 0 { 56 + args = append(args, fmt.Sprintf("--count=%d", opts.Offset+opts.Limit)) 57 + } 58 + 59 + args = append(args, opts.Pattern) 60 + 61 + output, err := g.forEachRef(args...) 38 62 if err != nil { 39 63 return nil, fmt.Errorf("failed to get tags: %w", err) 40 64 } ··· 44 68 return nil, nil 45 69 } 46 70 71 + startIdx := opts.Offset 72 + if startIdx >= len(records) { 73 + return nil, nil 74 + } 75 + 76 + endIdx := len(records) 77 + if opts.Limit > 0 { 78 + endIdx = min(startIdx+opts.Limit, len(records)) 79 + } 80 + 81 + records = records[startIdx:endIdx] 47 82 tags := make([]object.Tag, 0, len(records)) 48 83 49 84 for _, line := range records {
+365
knotserver/git/tag_test.go
··· 1 + package git 2 + 3 + import ( 4 + "path/filepath" 5 + "testing" 6 + "time" 7 + 8 + gogit "github.com/go-git/go-git/v5" 9 + "github.com/go-git/go-git/v5/plumbing" 10 + "github.com/go-git/go-git/v5/plumbing/object" 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 13 + "github.com/stretchr/testify/suite" 14 + ) 15 + 16 + type TagSuite struct { 17 + suite.Suite 18 + *RepoSuite 19 + } 20 + 21 + func TestTagSuite(t *testing.T) { 22 + t.Parallel() 23 + suite.Run(t, new(TagSuite)) 24 + } 25 + 26 + func (s *TagSuite) SetupTest() { 27 + s.RepoSuite = NewRepoSuite(s.T()) 28 + } 29 + 30 + func (s *TagSuite) TearDownTest() { 31 + s.RepoSuite.cleanup() 32 + } 33 + 34 + func (s *TagSuite) setupRepoWithTags() { 35 + s.init() 36 + 37 + // create commits for tagging 38 + commit1 := s.commitFile("file1.txt", "content 1", "Add file1") 39 + commit2 := s.commitFile("file2.txt", "content 2", "Add file2") 40 + commit3 := s.commitFile("file3.txt", "content 3", "Add file3") 41 + commit4 := s.commitFile("file4.txt", "content 4", "Add file4") 42 + commit5 := s.commitFile("file5.txt", "content 5", "Add file5") 43 + 44 + // create annotated tags 45 + s.createAnnotatedTag( 46 + "v1.0.0", 47 + commit1, 48 + "Tagger One", 49 + "tagger1@example.com", 50 + "Release version 1.0.0\n\nThis is the first stable release.", 51 + s.baseTime.Add(1*time.Hour), 52 + ) 53 + 54 + s.createAnnotatedTag( 55 + "v1.1.0", 56 + commit2, 57 + "Tagger Two", 58 + "tagger2@example.com", 59 + "Release version 1.1.0", 60 + s.baseTime.Add(2*time.Hour), 61 + ) 62 + 63 + // create lightweight tags 64 + s.createLightweightTag("v2.0.0", commit3) 65 + s.createLightweightTag("v2.1.0", commit4) 66 + 67 + // create another annotated tag 68 + s.createAnnotatedTag( 69 + "v3.0.0", 70 + commit5, 71 + "Tagger Three", 72 + "tagger3@example.com", 73 + "Major version 3.0.0\n\nBreaking changes included.", 74 + s.baseTime.Add(3*time.Hour), 75 + ) 76 + } 77 + 78 + func (s *TagSuite) TestTags_All() { 79 + s.setupRepoWithTags() 80 + 81 + tags, err := s.repo.Tags(nil) 82 + require.NoError(s.T(), err) 83 + 84 + // we created 5 tags total (3 annotated, 2 lightweight) 85 + assert.Len(s.T(), tags, 5, "expected 5 tags") 86 + 87 + // verify tags are sorted by creation date (newest first) 88 + expectedAnnotated := map[string]bool{ 89 + "v1.0.0": true, 90 + "v1.1.0": true, 91 + "v3.0.0": true, 92 + } 93 + 94 + expectedLightweight := map[string]bool{ 95 + "v2.0.0": true, 96 + "v2.1.0": true, 97 + } 98 + 99 + for _, tag := range tags { 100 + if expectedAnnotated[tag.Name] { 101 + // annotated tags should have tagger info 102 + assert.NotEmpty(s.T(), tag.Tagger.Name, "annotated tag %s should have tagger name", tag.Name) 103 + assert.NotEmpty(s.T(), tag.Message, "annotated tag %s should have message", tag.Name) 104 + } else if expectedLightweight[tag.Name] { 105 + // lightweight tags won't have tagger info or message (they'll have empty values) 106 + } else { 107 + s.T().Errorf("unexpected tag name: %s", tag.Name) 108 + } 109 + } 110 + } 111 + 112 + func (s *TagSuite) TestTags_WithLimit() { 113 + s.setupRepoWithTags() 114 + 115 + tests := []struct { 116 + name string 117 + limit int 118 + expectedCount int 119 + }{ 120 + { 121 + name: "limit 1", 122 + limit: 1, 123 + expectedCount: 1, 124 + }, 125 + { 126 + name: "limit 2", 127 + limit: 2, 128 + expectedCount: 2, 129 + }, 130 + { 131 + name: "limit 3", 132 + limit: 3, 133 + expectedCount: 3, 134 + }, 135 + { 136 + name: "limit 10 (more than available)", 137 + limit: 10, 138 + expectedCount: 5, 139 + }, 140 + } 141 + 142 + for _, tt := range tests { 143 + s.Run(tt.name, func() { 144 + tags, err := s.repo.Tags(&TagsOptions{ 145 + Limit: tt.limit, 146 + }) 147 + require.NoError(s.T(), err) 148 + assert.Len(s.T(), tags, tt.expectedCount, "expected %d tags", tt.expectedCount) 149 + }) 150 + } 151 + } 152 + 153 + func (s *TagSuite) TestTags_WithOffset() { 154 + s.setupRepoWithTags() 155 + 156 + tests := []struct { 157 + name string 158 + offset int 159 + expectedCount int 160 + }{ 161 + { 162 + name: "offset 0", 163 + offset: 0, 164 + expectedCount: 5, 165 + }, 166 + { 167 + name: "offset 1", 168 + offset: 1, 169 + expectedCount: 4, 170 + }, 171 + { 172 + name: "offset 2", 173 + offset: 2, 174 + expectedCount: 3, 175 + }, 176 + { 177 + name: "offset 4", 178 + offset: 4, 179 + expectedCount: 1, 180 + }, 181 + { 182 + name: "offset 5 (all skipped)", 183 + offset: 5, 184 + expectedCount: 0, 185 + }, 186 + { 187 + name: "offset 10 (more than available)", 188 + offset: 10, 189 + expectedCount: 0, 190 + }, 191 + } 192 + 193 + for _, tt := range tests { 194 + s.Run(tt.name, func() { 195 + tags, err := s.repo.Tags(&TagsOptions{ 196 + Offset: tt.offset, 197 + }) 198 + require.NoError(s.T(), err) 199 + assert.Len(s.T(), tags, tt.expectedCount, "expected %d tags", tt.expectedCount) 200 + }) 201 + } 202 + } 203 + 204 + func (s *TagSuite) TestTags_WithLimitAndOffset() { 205 + s.setupRepoWithTags() 206 + 207 + tests := []struct { 208 + name string 209 + limit int 210 + offset int 211 + expectedCount int 212 + }{ 213 + { 214 + name: "limit 2, offset 0", 215 + limit: 2, 216 + offset: 0, 217 + expectedCount: 2, 218 + }, 219 + { 220 + name: "limit 2, offset 1", 221 + limit: 2, 222 + offset: 1, 223 + expectedCount: 2, 224 + }, 225 + { 226 + name: "limit 2, offset 3", 227 + limit: 2, 228 + offset: 3, 229 + expectedCount: 2, 230 + }, 231 + { 232 + name: "limit 2, offset 4", 233 + limit: 2, 234 + offset: 4, 235 + expectedCount: 1, 236 + }, 237 + { 238 + name: "limit 3, offset 2", 239 + limit: 3, 240 + offset: 2, 241 + expectedCount: 3, 242 + }, 243 + { 244 + name: "limit 10, offset 3", 245 + limit: 10, 246 + offset: 3, 247 + expectedCount: 2, 248 + }, 249 + } 250 + 251 + for _, tt := range tests { 252 + s.Run(tt.name, func() { 253 + tags, err := s.repo.Tags(&TagsOptions{ 254 + Limit: tt.limit, 255 + Offset: tt.offset, 256 + }) 257 + require.NoError(s.T(), err) 258 + assert.Len(s.T(), tags, tt.expectedCount, "expected %d tags", tt.expectedCount) 259 + }) 260 + } 261 + } 262 + 263 + func (s *TagSuite) TestTags_EmptyRepo() { 264 + repoPath := filepath.Join(s.tempDir, "empty-repo") 265 + 266 + _, err := gogit.PlainInit(repoPath, false) 267 + require.NoError(s.T(), err) 268 + 269 + gitRepo, err := PlainOpen(repoPath) 270 + require.NoError(s.T(), err) 271 + 272 + tags, err := gitRepo.Tags(nil) 273 + require.NoError(s.T(), err) 274 + 275 + if tags != nil { 276 + assert.Empty(s.T(), tags, "expected no tags in empty repo") 277 + } 278 + } 279 + 280 + func (s *TagSuite) TestTags_Pagination() { 281 + s.setupRepoWithTags() 282 + 283 + allTags, err := s.repo.Tags(nil) 284 + require.NoError(s.T(), err) 285 + assert.Len(s.T(), allTags, 5, "expected 5 tags") 286 + 287 + pageSize := 2 288 + var paginatedTags []object.Tag 289 + 290 + for offset := 0; offset < len(allTags); offset += pageSize { 291 + tags, err := s.repo.Tags(&TagsOptions{ 292 + Limit: pageSize, 293 + Offset: offset, 294 + }) 295 + require.NoError(s.T(), err) 296 + paginatedTags = append(paginatedTags, tags...) 297 + } 298 + 299 + assert.Len(s.T(), paginatedTags, len(allTags), "pagination should return all tags") 300 + 301 + for i := range allTags { 302 + assert.Equal(s.T(), allTags[i].Name, paginatedTags[i].Name, 303 + "tag at index %d differs", i) 304 + } 305 + } 306 + 307 + func (s *TagSuite) TestTags_VerifyAnnotatedTagFields() { 308 + s.setupRepoWithTags() 309 + 310 + tags, err := s.repo.Tags(nil) 311 + require.NoError(s.T(), err) 312 + 313 + var v1Tag *object.Tag 314 + for i := range tags { 315 + if tags[i].Name == "v1.0.0" { 316 + v1Tag = &tags[i] 317 + break 318 + } 319 + } 320 + 321 + require.NotNil(s.T(), v1Tag, "v1.0.0 tag not found") 322 + 323 + assert.Equal(s.T(), "Tagger One", v1Tag.Tagger.Name, "tagger name should match") 324 + assert.Equal(s.T(), "tagger1@example.com", v1Tag.Tagger.Email, "tagger email should match") 325 + 326 + assert.Equal(s.T(), "Release version 1.0.0\n\nThis is the first stable release.", 327 + v1Tag.Message, "tag message should match") 328 + 329 + assert.Equal(s.T(), plumbing.TagObject, v1Tag.TargetType, 330 + "target type should be CommitObject") 331 + 332 + assert.False(s.T(), v1Tag.Hash.IsZero(), "tag hash should be set") 333 + 334 + assert.False(s.T(), v1Tag.Target.IsZero(), "target hash should be set") 335 + } 336 + 337 + func (s *TagSuite) TestTags_NilOptions() { 338 + s.setupRepoWithTags() 339 + 340 + tags, err := s.repo.Tags(nil) 341 + require.NoError(s.T(), err) 342 + assert.Len(s.T(), tags, 5, "nil options should return all tags") 343 + } 344 + 345 + func (s *TagSuite) TestTags_ZeroLimitAndOffset() { 346 + s.setupRepoWithTags() 347 + 348 + tags, err := s.repo.Tags(&TagsOptions{ 349 + Limit: 0, 350 + Offset: 0, 351 + }) 352 + require.NoError(s.T(), err) 353 + assert.Len(s.T(), tags, 5, "zero limit should return all tags") 354 + } 355 + 356 + func (s *TagSuite) TestTags_Pattern() { 357 + s.setupRepoWithTags() 358 + 359 + v1tag, err := s.repo.Tags(&TagsOptions{ 360 + Pattern: "refs/tags/v1.0.0", 361 + }) 362 + 363 + require.NoError(s.T(), err) 364 + assert.Len(s.T(), v1tag, 1, "expected 1 tag") 365 + }
+15 -22
knotserver/xrpc/repo_tags.go
··· 20 20 return 21 21 } 22 22 23 - cursor := r.URL.Query().Get("cursor") 23 + // default 24 + limit := 50 25 + offset := 0 24 26 25 - limit := 50 // default 26 - if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 - limit = l 29 - } 27 + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + 31 + if o, err := strconv.Atoi(r.URL.Query().Get("cursor")); err == nil && o > 0 { 32 + offset = o 30 33 } 31 34 32 35 gr, err := git.PlainOpen(repoPath) ··· 36 39 return 37 40 } 38 41 39 - tags, err := gr.Tags() 42 + tags, err := gr.Tags(&git.TagsOptions{ 43 + Limit: limit, 44 + Offset: offset, 45 + }) 46 + 40 47 if err != nil { 41 48 x.Logger.Warn("getting tags", "error", err.Error()) 42 49 tags = []object.Tag{} ··· 64 71 rtags = append(rtags, &tr) 65 72 } 66 73 67 - // apply pagination manually 68 - offset := 0 69 - if cursor != "" { 70 - if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 71 - offset = o 72 - } 73 - } 74 - 75 - // calculate end index 76 - end := min(offset+limit, len(rtags)) 77 - 78 - paginatedTags := rtags[offset:end] 79 - 80 - // Create response using existing types.RepoTagsResponse 81 74 response := types.RepoTagsResponse{ 82 - Tags: paginatedTags, 75 + Tags: rtags, 83 76 } 84 77 85 78 writeJson(w, response)