Monorepo for Tangled tangled.org
10

Configure Feed

Select the types of activity you want to include in your feed.

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}