Monorepo for Tangled
tangled.org
1package pulls
2
3import (
4 "io"
5 "log/slog"
6 "net/url"
7 "reflect"
8 "testing"
9 "time"
10
11 "github.com/go-git/go-git/v5/plumbing/object"
12 "tangled.org/core/appview/models"
13 "tangled.org/core/appview/pages"
14 "tangled.org/core/appview/pages/repoinfo"
15 "tangled.org/core/appview/validator"
16 "tangled.org/core/patchutil"
17 "tangled.org/core/types"
18)
19
20func TestBracketComponents(t *testing.T) {
21 cases := []struct {
22 key, prefix string
23 want []string
24 ok bool
25 }{
26 {"foo[a]", "foo", []string{"a"}, true},
27 {"foo[a][b]", "foo", []string{"a", "b"}, true},
28 {"foo[a][b][c]", "foo", []string{"a", "b", "c"}, true},
29 {"foo[]", "foo", []string{""}, true},
30 {"foo[a][]", "foo", []string{"a", ""}, true},
31 {"foo", "foo", nil, false},
32 {"bar[a]", "foo", nil, false},
33 {"foo[a", "foo", nil, false},
34 {"fooa]", "foo", nil, false},
35 {"foo[a]extra", "foo", nil, false},
36 {"", "foo", nil, false},
37 }
38 for _, c := range cases {
39 got, ok := bracketComponents(c.key, c.prefix)
40 if ok != c.ok || !reflect.DeepEqual(got, c.want) {
41 t.Errorf("bracketComponents(%q, %q) = %v, %v; want %v, %v", c.key, c.prefix, got, ok, c.want, c.ok)
42 }
43 }
44}
45
46func TestParseBracketedForm(t *testing.T) {
47 form := url.Values{
48 "stackTitle[abc]": {"hello"},
49 "stackTitle[xyz]": {"world", "ignored"},
50 "stackTitle[]": {"empty-id"},
51 "stackTitle[a][b]": {"too-deep"},
52 "stackTitle": {"no-bracket"},
53 "unrelated[abc]": {"skip"},
54 "stackTitle[noval]": {},
55 }
56 got := parseBracketedForm(form, "stackTitle")
57 want := map[string]string{
58 "abc": "hello",
59 "xyz": "world",
60 }
61 if !reflect.DeepEqual(got, want) {
62 t.Errorf("parseBracketedForm = %v; want %v", got, want)
63 }
64}
65
66func TestParseStackLabelForms(t *testing.T) {
67 form := url.Values{
68 "stackLabel[c1][at://uri/a]": {"v1"},
69 "stackLabel[c1][at://uri/b]": {"v2"},
70 "stackLabel[c2][at://uri/a]": {"v3", "v4"},
71 "stackLabel[c1][]": {"empty-uri"},
72 "stackLabel[][at://uri/a]": {"empty-cid"},
73 "stackLabel[c1]": {"missing-second-bracket"},
74 "stackLabel[c1][a][b]": {"too-deep"},
75 "stackTitle[c1]": {"wrong-prefix"},
76 }
77 got := parseStackLabelForms(form)
78 want := map[string]url.Values{
79 "c1": {
80 "at://uri/a": {"v1"},
81 "at://uri/b": {"v2"},
82 },
83 "c2": {
84 "at://uri/a": {"v3", "v4"},
85 },
86 }
87 if !reflect.DeepEqual(got, want) {
88 t.Errorf("parseStackLabelForms = %v; want %v", got, want)
89 }
90}
91
92func TestDefaultTargetBranch(t *testing.T) {
93 branches := []types.Branch{
94 {Reference: types.Reference{Name: "feature"}},
95 {Reference: types.Reference{Name: "main"}, IsDefault: true},
96 }
97 cases := []struct {
98 name string
99 branches []types.Branch
100 current string
101 want string
102 }{
103 {"current is valid", branches, "feature", "feature"},
104 {"current is default", branches, "main", "main"},
105 {"current invalid, falls to default", branches, "ghost", "main"},
106 {"current empty, falls to default", branches, "", "main"},
107 {"no default, no match returns empty", []types.Branch{{Reference: types.Reference{Name: "only"}}}, "ghost", ""},
108 {"empty branches returns empty", nil, "anything", ""},
109 }
110 for _, c := range cases {
111 t.Run(c.name, func(t *testing.T) {
112 if got := defaultTargetBranch(c.branches, c.current); got != c.want {
113 t.Errorf("defaultTargetBranch = %q; want %q", got, c.want)
114 }
115 })
116 }
117}
118
119func TestDefaultSourceBranch(t *testing.T) {
120 choices := []types.Branch{
121 {Reference: types.Reference{Name: "feature"}},
122 {Reference: types.Reference{Name: "wip"}},
123 }
124 forks := []types.Branch{
125 {Reference: types.Reference{Name: "fork-feature"}},
126 }
127 cases := []struct {
128 name string
129 source pages.Source
130 current string
131 want string
132 }{
133 {"branch source, valid current", pages.SourceBranch, "feature", "feature"},
134 {"branch source, invalid falls to first", pages.SourceBranch, "ghost", "feature"},
135 {"branch source, empty falls to first", pages.SourceBranch, "", "feature"},
136 {"fork source, valid current", pages.SourceFork, "fork-feature", "fork-feature"},
137 {"fork source, invalid falls to first fork", pages.SourceFork, "ghost", "fork-feature"},
138 {"patch source preserves current", pages.SourcePatch, "anything", "anything"},
139 }
140 for _, c := range cases {
141 t.Run(c.name, func(t *testing.T) {
142 if got := defaultSourceBranch(c.source, c.current, choices, forks); got != c.want {
143 t.Errorf("defaultSourceBranch = %q; want %q", got, c.want)
144 }
145 })
146 }
147 if got := defaultSourceBranch(pages.SourceBranch, "", nil, nil); got != "" {
148 t.Errorf("empty choices should return empty, got %q", got)
149 }
150}
151
152func TestSortBranchesByRecency(t *testing.T) {
153 mk := func(name string, when *time.Time) types.Branch {
154 b := types.Branch{Reference: types.Reference{Name: name}}
155 if when != nil {
156 b.Commit = &object.Commit{Committer: object.Signature{When: *when}}
157 }
158 return b
159 }
160 t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
161 t2 := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
162 t3 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
163
164 in := []types.Branch{
165 mk("oldest", &t1),
166 mk("newest", &t3),
167 mk("nil-commit", nil),
168 mk("middle", &t2),
169 }
170 got := sortBranchesByRecency(in)
171 wantNames := []string{"newest", "middle", "oldest", "nil-commit"}
172 for i, want := range wantNames {
173 if got[i].Reference.Name != want {
174 t.Errorf("position %d: got %q, want %q", i, got[i].Reference.Name, want)
175 }
176 }
177
178 if &got[0] == &in[0] {
179 t.Error("expected new slice, got aliased input")
180 }
181}
182
183func TestComposeCanonicalURL(t *testing.T) {
184 repo := repoinfo.RepoInfo{OwnerDid: "did:plc:abc", Name: "demo", Rkey: "demo"}
185 cases := []struct {
186 name string
187 p pages.RepoNewPullParams
188 want string
189 }{
190 {
191 "defaults",
192 pages.RepoNewPullParams{RepoInfo: repo, Source: pages.SourceBranch},
193 "/did:plc:abc/demo/pulls/new",
194 },
195 {
196 "stacked",
197 pages.RepoNewPullParams{RepoInfo: repo, Source: pages.SourceBranch, IsStacked: true},
198 "/did:plc:abc/demo/pulls/new?mode=stack",
199 },
200 {
201 "fork with selection",
202 pages.RepoNewPullParams{
203 RepoInfo: repo,
204 Source: pages.SourceFork,
205 Fork: "did:plc:limpet",
206 SourceBranch: "feature",
207 TargetBranch: "main",
208 },
209 "/did:plc:abc/demo/pulls/new?fork=did%3Aplc%3Alimpet&source=fork&sourceBranch=feature&targetBranch=main",
210 },
211 {
212 "branch with selection drops source param",
213 pages.RepoNewPullParams{
214 RepoInfo: repo,
215 Source: pages.SourceBranch,
216 SourceBranch: "feature",
217 TargetBranch: "main",
218 },
219 "/did:plc:abc/demo/pulls/new?sourceBranch=feature&targetBranch=main",
220 },
221 {
222 "fork field skipped when source != fork",
223 pages.RepoNewPullParams{
224 RepoInfo: repo,
225 Source: pages.SourceBranch,
226 Fork: "stale",
227 },
228 "/did:plc:abc/demo/pulls/new",
229 },
230 }
231 for _, c := range cases {
232 t.Run(c.name, func(t *testing.T) {
233 if got := composeCanonicalURL(c.p); got != c.want {
234 t.Errorf("composeCanonicalURL = %q; want %q", got, c.want)
235 }
236 })
237 }
238}
239
240func TestLabelStateFromForm(t *testing.T) {
241 bug := &models.LabelDefinition{
242 Did: "did:plc:test", Rkey: "bug", Name: "bug",
243 ValueType: models.ValueType{Type: models.ConcreteTypeNull},
244 Scope: []string{"sh.tangled.repo.pull"},
245 }
246 priority := &models.LabelDefinition{
247 Did: "did:plc:test", Rkey: "priority", Name: "priority",
248 ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}},
249 Scope: []string{"sh.tangled.repo.pull"},
250 }
251 defs := map[string]*models.LabelDefinition{
252 bug.AtUri().String(): bug,
253 priority.AtUri().String(): priority,
254 }
255
256 form := url.Values{
257 bug.AtUri().String(): {"null"},
258 priority.AtUri().String(): {"high", ""},
259 "unrelated": {"ignored"},
260 }
261 state := labelStateFromForm(form, defs)
262 if !state.ContainsLabel(bug.AtUri().String()) {
263 t.Error("expected bug label in state")
264 }
265 if !state.ContainsLabel(priority.AtUri().String()) {
266 t.Error("expected priority label in state")
267 }
268
269 emptyState := labelStateFromForm(url.Values{}, defs)
270 if emptyState.ContainsLabel(bug.AtUri().String()) {
271 t.Error("empty form should produce empty state")
272 }
273}
274
275func TestStackPerCommitDiffs(t *testing.T) {
276 if got := stackPerCommitDiffs(nil, "main", "", nil); got != nil {
277 t.Errorf("nil comparison should return nil, got %v", got)
278 }
279
280 formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001
281From: Test <t@e.st>
282Date: Tue, 1 Jan 2020 00:00:00 +0000
283Subject: [PATCH] one
284Change-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
285
286---
287 a.txt | 1 +
288 1 file changed, 1 insertion(+)
289
290diff --git a/a.txt b/a.txt
291index 0000000..1111111 100644
292--- a/a.txt
293+++ b/a.txt
294@@ -0,0 +1 @@
295+hello
296`
297 patches, err := patchutil.ExtractPatches(formatPatch)
298 if err != nil {
299 t.Fatalf("extract: %v", err)
300 }
301 if len(patches) != 1 {
302 t.Fatalf("expected 1 patch, got %d", len(patches))
303 }
304 if cid, err := patches[0].ChangeId(); err != nil || cid == "" {
305 t.Fatalf("change-id missing from fixture: %v %q", err, cid)
306 }
307 comp := &types.RepoFormatPatchResponse{
308 FormatPatchRaw: formatPatch,
309 FormatPatch: patches,
310 }
311
312 got := stackPerCommitDiffs(comp, "main", "/repo/pulls/new/refresh", map[string]string{
313 "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": "split",
314 })
315 if len(got) != 1 {
316 t.Fatalf("expected 1 entry, got %d", len(got))
317 }
318 if got[0].Diff == nil {
319 t.Error("Diff should be set")
320 }
321 if !got[0].Opts.Split {
322 t.Error("Split should propagate from stackSplits")
323 }
324 if got[0].Opts.RefreshUrl != "/repo/pulls/new/refresh" {
325 t.Errorf("RefreshUrl: got %q", got[0].Opts.RefreshUrl)
326 }
327 if got[0].Opts.Target != "#stack-diff-Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
328 t.Errorf("Target: got %q", got[0].Opts.Target)
329 }
330 if got[0].Opts.Field != "stackSplit[Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]" {
331 t.Errorf("Field: got %q", got[0].Opts.Field)
332 }
333}
334
335func TestStackPerCommitDiffsNoChangeId(t *testing.T) {
336 formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001
337From: Test <t@e.st>
338Date: Tue, 1 Jan 2020 00:00:00 +0000
339Subject: [PATCH] no-cid
340
341---
342 a.txt | 1 +
343 1 file changed, 1 insertion(+)
344
345diff --git a/a.txt b/a.txt
346index 0000000..1111111 100644
347--- a/a.txt
348+++ b/a.txt
349@@ -0,0 +1 @@
350+hello
351`
352 patches, err := patchutil.ExtractPatches(formatPatch)
353 if err != nil {
354 t.Fatalf("extract: %v", err)
355 }
356 comp := &types.RepoFormatPatchResponse{
357 FormatPatchRaw: formatPatch,
358 FormatPatch: patches,
359 }
360 got := stackPerCommitDiffs(comp, "main", "/r", nil)
361 if len(got) != 1 {
362 t.Fatalf("len: %d", len(got))
363 }
364 if got[0].Diff == nil {
365 t.Error("Diff still set even without change-id")
366 }
367 if got[0].Opts != (types.DiffOpts{}) {
368 t.Errorf("Opts should be zero without change-id, got %+v", got[0].Opts)
369 }
370}
371
372func TestPrefetchComparisonPatch(t *testing.T) {
373 s := &Pulls{
374 validator: &validator.Validator{},
375 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
376 }
377
378 cases := []struct {
379 name string
380 patch string
381 wantNil bool
382 wantErr bool
383 }{
384 {"empty patch returns nil", "", true, false},
385 {"whitespace patch returns nil", " \n ", true, false},
386 {"garbage patch errors", "not a patch", false, true},
387 }
388 for _, c := range cases {
389 t.Run(c.name, func(t *testing.T) {
390 comp, diff, err := s.prefetchComparison(nil, nil, pages.SourcePatch, "", "", "", c.patch)
391 if c.wantErr {
392 if err == nil {
393 t.Fatal("expected error")
394 }
395 return
396 }
397 if err != nil {
398 t.Fatalf("unexpected error: %v", err)
399 }
400 if c.wantNil {
401 if comp != nil || diff != nil {
402 t.Errorf("expected nil, got comp=%v diff=%v", comp, diff)
403 }
404 }
405 })
406 }
407}
408
409func TestPrefetchComparisonValidPatch(t *testing.T) {
410 s := &Pulls{
411 validator: &validator.Validator{},
412 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
413 }
414 patch := `diff --git a/a.txt b/a.txt
415index 0000000..1111111 100644
416--- a/a.txt
417+++ b/a.txt
418@@ -0,0 +1 @@
419+hello
420`
421 comp, diff, err := s.prefetchComparison(nil, nil, pages.SourcePatch, "", "main", "", patch)
422 if err != nil {
423 t.Fatalf("err: %v", err)
424 }
425 if comp == nil {
426 t.Fatal("comp nil")
427 }
428 if comp.FormatPatchRaw == "" {
429 t.Error("FormatPatchRaw empty")
430 }
431 if diff == nil {
432 t.Error("diff nil")
433 }
434}
435
436func TestPrefetchComparisonMissingInputs(t *testing.T) {
437 s := &Pulls{
438 validator: &validator.Validator{},
439 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
440 }
441
442 cases := []struct {
443 name string
444 source pages.Source
445 fork string
446 targetBranch string
447 sourceBranch string
448 }{
449 {"branch missing target", pages.SourceBranch, "", "", "feature"},
450 {"branch missing source", pages.SourceBranch, "", "main", ""},
451 {"fork missing fork", pages.SourceFork, "", "main", "feature"},
452 {"fork missing target", pages.SourceFork, "did:plc:limpet", "", "feature"},
453 {"fork missing source", pages.SourceFork, "did:plc:limpet", "main", ""},
454 {"unknown source", pages.Source("bogus"), "", "", ""},
455 }
456 for _, c := range cases {
457 t.Run(c.name, func(t *testing.T) {
458 comp, diff, err := s.prefetchComparison(nil, nil, c.source, c.fork, c.targetBranch, c.sourceBranch, "")
459 if err != nil {
460 t.Errorf("expected nil err, got %v", err)
461 }
462 if comp != nil || diff != nil {
463 t.Errorf("expected nil result, got comp=%v diff=%v", comp, diff)
464 }
465 })
466 }
467}