Monorepo for Tangled tangled.org
5

Configure Feed

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

at icy/qmlqxq 13 kB View raw
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}