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