Monorepo for Tangled tangled.org
859
fork

Configure Feed

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

appview/pulls: test compose label, branch, patch helpers

Lewis: May this revision serve well! <lewis@tangled.org>

Lewis 92e7e413 d2331dae

+467
+467
appview/pulls/compose_helpers_test.go
··· 1 + package pulls 2 + 3 + import ( 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 + 20 + func 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 + 46 + func 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 + 66 + func 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 + 92 + func 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 + 119 + func 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 + 152 + func 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 + 183 + func TestComposeCanonicalURL(t *testing.T) { 184 + repo := repoinfo.RepoInfo{OwnerDid: "did:plc:abc", Name: "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:other/repo", 206 + SourceBranch: "feature", 207 + TargetBranch: "main", 208 + }, 209 + "/did:plc:abc/demo/pulls/new?fork=did%3Aplc%3Aother%2Frepo&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 + 240 + func 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 + 275 + func 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 281 + From: Test <t@e.st> 282 + Date: Tue, 1 Jan 2020 00:00:00 +0000 283 + Subject: [PATCH] one 284 + Change-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 285 + 286 + --- 287 + a.txt | 1 + 288 + 1 file changed, 1 insertion(+) 289 + 290 + diff --git a/a.txt b/a.txt 291 + index 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 + 335 + func TestStackPerCommitDiffsNoChangeId(t *testing.T) { 336 + formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 337 + From: Test <t@e.st> 338 + Date: Tue, 1 Jan 2020 00:00:00 +0000 339 + Subject: [PATCH] no-cid 340 + 341 + --- 342 + a.txt | 1 + 343 + 1 file changed, 1 insertion(+) 344 + 345 + diff --git a/a.txt b/a.txt 346 + index 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 + 372 + func 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 + 409 + func 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 415 + index 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 + 436 + func 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/repo", "", "feature"}, 453 + {"fork missing source", pages.SourceFork, "did/repo", "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 + }