mr: close in favour of another merge request !413
13 files changed, +449 −25
Layout: unified · split
.gitbay/wiki/Parity.org +1
| @@ -41,6 +41,7 @@ browser-only and the iOS build screen unable to say more than the log. | |||
| 41 | | comment on a diff line | yes | yes | yes | | 41 | | comment on a diff line | yes | yes | yes | |
| 42 | | merge (all strategies) | yes | yes | yes | | 42 | | merge (all strategies) | yes | yes | yes | |
| 43 | | close | yes | yes | yes | | 43 | | close | yes | yes | yes | |
| 44 | | close in favour of another | yes | yes | no | | ||
| 44 | | create | yes | yes | yes | | 45 | | create | yes | yes | yes | |
| 45 | | draft, ready | yes | yes | yes | | 46 | | draft, ready | yes | yes | yes | |
| 46 | | search title and body | yes | yes | yes | | 47 | | search title and body | yes | yes | yes | |
.gitbay/wiki/Users.org +5
| @@ -415,6 +415,11 @@ gitbay mr merge 4 [--strategy ff|merge|squash|rebase] | |||
| 415 | gitbay mr close 4 | 415 | gitbay mr close 4 |
| 416 | #+end_src | 416 | #+end_src |
| 417 | 417 | ||
| 418 | A merge request closed without merging can name the one that carries | ||
| 419 | its change forward: =mr close 4 --by 7= records it and both pages show | ||
| 420 | it, and =mr edit 4 --superseded-by 7|none= sets or clears it | ||
| 421 | afterwards, refused on anything but a closed merge request. | ||
| 422 | |||
| 418 | Semantics worth knowing: | 423 | Semantics worth knowing: |
| 419 | 424 | ||
| 420 | - the MR head lives in the *target* repository as | 425 | - the MR head lives in the *target* repository as |
e2e/mrweb_test.go +65
| @@ -388,3 +388,68 @@ func TestMRDiffEmptyExplained(t *testing.T) { | |||
| 388 | t.Fatalf("empty diff unexplained:\n%s", body) | 388 | t.Fatalf("empty diff unexplained:\n%s", body) |
| 389 | } | 389 | } |
| 390 | } | 390 | } |
| 391 | |||
| 392 | // TestMRSupersedes closes one merge request in favour of another from the | ||
| 393 | // web form, and checks both pages say so; clearing it over ssh removes | ||
| 394 | // both lines again (#223). | ||
| 395 | func TestMRSupersedes(t *testing.T) { | ||
| 396 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 397 | aliceKey := inst.newKey(t, "alice") | ||
| 398 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 399 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 400 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | ||
| 401 | t.Fatalf("repo create: %s", errOut) | ||
| 402 | } | ||
| 403 | env := inst.gitEnv(aliceKey) | ||
| 404 | work := t.TempDir() | ||
| 405 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 406 | dir := filepath.Join(work, "w") | ||
| 407 | os.WriteFile(filepath.Join(dir, "README"), []byte("base\n"), 0o644) | ||
| 408 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 409 | mustGit(t, dir, env, "add", ".") | ||
| 410 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 411 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 412 | |||
| 413 | for _, branch := range []string{"one", "two"} { | ||
| 414 | mustGit(t, dir, env, "checkout", "-q", "main") | ||
| 415 | mustGit(t, dir, env, "checkout", "-q", "-b", branch) | ||
| 416 | os.WriteFile(filepath.Join(dir, branch+".txt"), []byte(branch+"\n"), 0o644) | ||
| 417 | mustGit(t, dir, env, "add", ".") | ||
| 418 | mustGit(t, dir, env, "commit", "-q", "-m", branch) | ||
| 419 | mustGit(t, dir, env, "push", "-q", "origin", branch) | ||
| 420 | } | ||
| 421 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app", | ||
| 422 | "--source", "one", "--target", "main", "--title", "one"); code != 0 { | ||
| 423 | t.Fatalf("mr create one: %s", errOut) | ||
| 424 | } | ||
| 425 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app", | ||
| 426 | "--source", "two", "--target", "main", "--title", "two"); code != 0 { | ||
| 427 | t.Fatalf("mr create two: %s", errOut) | ||
| 428 | } | ||
| 429 | |||
| 430 | alice := inst.login(t, aliceKey) | ||
| 431 | if status, body := browserPost(t, alice, inst.base()+"/alice/app/mrs/1/close", url.Values{"by": {"2"}}); status != 200 { | ||
| 432 | t.Fatalf("close post: %d\n%s", status, body) | ||
| 433 | } | ||
| 434 | |||
| 435 | _, body1 := browserGet(t, alice, inst.base()+"/alice/app/mrs/1") | ||
| 436 | if !strings.Contains(body1, `in favour of <a href="/alice/app/mrs/2">!2</a>`) { | ||
| 437 | t.Fatalf("!1 does not say it was superseded:\n%s", body1) | ||
| 438 | } | ||
| 439 | _, body2 := browserGet(t, alice, inst.base()+"/alice/app/mrs/2") | ||
| 440 | if !strings.Contains(body2, `supersedes <a href="/alice/app/mrs/1">!1</a>`) { | ||
| 441 | t.Fatalf("!2 does not say what it supersedes:\n%s", body2) | ||
| 442 | } | ||
| 443 | |||
| 444 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "edit", "alice/app", "1", "--superseded-by", "none"); code != 0 { | ||
| 445 | t.Fatalf("mr edit --superseded-by none: %s", errOut) | ||
| 446 | } | ||
| 447 | _, body1 = browserGet(t, alice, inst.base()+"/alice/app/mrs/1") | ||
| 448 | if strings.Contains(body1, "in favour of") { | ||
| 449 | t.Fatalf("!1 still says it was superseded after clearing:\n%s", body1) | ||
| 450 | } | ||
| 451 | _, body2 = browserGet(t, alice, inst.base()+"/alice/app/mrs/2") | ||
| 452 | if strings.Contains(body2, "supersedes") { | ||
| 453 | t.Fatalf("!2 still says it supersedes after clearing:\n%s", body2) | ||
| 454 | } | ||
| 455 | } | ||
internal/control/issue.go +21 −11
| @@ -289,12 +289,15 @@ func setIssueState(c *Ctx, args []string, state string) int { | |||
| 289 | } | 289 | } |
| 290 | 290 | ||
| 291 | // editText parses --title/--body/--file -/--format and authorizes: author or | 291 | // editText parses --title/--body/--file -/--format and authorizes: author or |
| 292 | // write. A nil format means the stored markup format stays as it is. | 292 | // write. A nil format means the stored markup format stays as it is. extra |
| 293 | func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) { | 293 | // names further value flags a caller wants (mr edit's --superseded-by): |
| 294 | f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--body", "--file", "--format"}, MaxPos: -1, | 294 | // they are accepted and reported in the returned flags, and count toward |
| 295 | // "at least one edit was given" alongside title/body/format. | ||
| 296 | func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) { | ||
| 297 | f, err := parseFlags(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1, | ||
| 295 | Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"}) | 298 | Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"}) |
| 296 | if err != nil { | 299 | if err != nil { |
| 297 | return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err) | 300 | return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err) |
| 298 | } | 301 | } |
| 299 | rest = f.Pos | 302 | rest = f.Pos |
| 300 | titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format") | 303 | titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format") |
| @@ -302,20 +305,27 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body, f | |||
| 302 | if file != "" { | 305 | if file != "" { |
| 303 | b, err := bodyFrom(c, "", file) | 306 | b, err := bodyFrom(c, "", file) |
| 304 | if err != nil { | 307 | if err != nil { |
| 305 | return nil, nil, nil, nil, c.failInput(err) | 308 | return nil, nil, nil, nil, flags{}, c.failInput(err) |
| 306 | } | 309 | } |
| 307 | bodyV, haveBody = b, true | 310 | bodyV, haveBody = b, true |
| 308 | } | 311 | } |
| 309 | fmtName, err := markupFormat(formatV) | 312 | fmtName, err := markupFormat(formatV) |
| 310 | if err != nil { | 313 | if err != nil { |
| 311 | return nil, nil, nil, nil, c.failInput(err) | 314 | return nil, nil, nil, nil, flags{}, c.failInput(err) |
| 312 | } | 315 | } |
| 313 | if !haveTitle && !haveBody && fmtName == "" { | 316 | anyExtra := false |
| 314 | return nil, nil, nil, nil, c.usage() | 317 | for _, e := range extra { |
| 318 | if f.Has(e) { | ||
| 319 | anyExtra = true | ||
| 320 | break | ||
| 321 | } | ||
| 322 | } | ||
| 323 | if !haveTitle && !haveBody && fmtName == "" && !anyExtra { | ||
| 324 | return nil, nil, nil, nil, flags{}, c.usage() | ||
| 315 | } | 325 | } |
| 316 | if haveTitle { | 326 | if haveTitle { |
| 317 | if strings.TrimSpace(titleV) == "" { | 327 | if strings.TrimSpace(titleV) == "" { |
| 318 | return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty") | 328 | return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "--title must not be empty") |
| 319 | } | 329 | } |
| 320 | title = &titleV | 330 | title = &titleV |
| 321 | } | 331 | } |
| @@ -325,11 +335,11 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body, f | |||
| 325 | if fmtName != "" { | 335 | if fmtName != "" { |
| 326 | format = &fmtName | 336 | format = &fmtName |
| 327 | } | 337 | } |
| 328 | return rest, title, body, format, -1 | 338 | return rest, title, body, format, f, -1 |
| 329 | } | 339 | } |
| 330 | 340 | ||
| 331 | func runIssueEdit(c *Ctx, args []string) int { | 341 | func runIssueEdit(c *Ctx, args []string) int { |
| 332 | rest, title, body, format, code := editText(c, args, "issue") | 342 | rest, title, body, format, _, code := editText(c, args, "issue") |
| 333 | if code >= 0 { | 343 | if code >= 0 { |
| 334 | return code | 344 | return code |
| 335 | } | 345 | } |
internal/control/mr.go +76 −7
| @@ -68,7 +68,7 @@ func init() { | |||
| 68 | Usage: "mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff}) | 68 | Usage: "mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff}) |
| 69 | register(Command{Path: []string{"mr", "edit"}, | 69 | register(Command{Path: []string{"mr", "edit"}, |
| 70 | Summary: "edit title or body", | 70 | Summary: "edit title or body", |
| 71 | Usage: "mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", | 71 | Usage: "mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org] [--superseded-by <m>|none]", |
| 72 | ReadsStdin: true, Run: runMREdit}) | 72 | ReadsStdin: true, Run: runMREdit}) |
| 73 | register(Command{Path: []string{"mr", "retarget"}, | 73 | register(Command{Path: []string{"mr", "retarget"}, |
| 74 | Summary: "retarget onto another branch", | 74 | Summary: "retarget onto another branch", |
| @@ -88,7 +88,7 @@ func init() { | |||
| 88 | Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge}) | 88 | Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge}) |
| 89 | register(Command{Path: []string{"mr", "close"}, | 89 | register(Command{Path: []string{"mr", "close"}, |
| 90 | Summary: "close without merging", | 90 | Summary: "close without merging", |
| 91 | Usage: "mr close <owner/name> <n>", Run: runMRClose}) | 91 | Usage: "mr close <owner/name> <n> [--by <m>]", Run: runMRClose}) |
| 92 | } | 92 | } |
| 93 | 93 | ||
| 94 | // ForkOut is what `repo fork` emits: where the fork landed, and what it | 94 | // ForkOut is what `repo fork` emits: where the fork landed, and what it |
| @@ -384,6 +384,9 @@ type mrOut struct { | |||
| 384 | MergedBy string `json:"merged_by,omitempty"` | 384 | MergedBy string `json:"merged_by,omitempty"` |
| 385 | ClosedAt string `json:"closed_at,omitempty"` | 385 | ClosedAt string `json:"closed_at,omitempty"` |
| 386 | ClosedBy string `json:"closed_by,omitempty"` | 386 | ClosedBy string `json:"closed_by,omitempty"` |
| 387 | // SupersededBy is the merge request, by number, this one was closed | ||
| 388 | // in favour of. 0 means none. | ||
| 389 | SupersededBy int64 `json:"superseded_by,omitempty"` | ||
| 387 | } | 390 | } |
| 388 | 391 | ||
| 389 | type stackRef struct { | 392 | type stackRef struct { |
| @@ -429,7 +432,7 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { | |||
| 429 | Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone, | 432 | Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone, |
| 430 | ReviewRequests: m.ReviewRequests, | 433 | ReviewRequests: m.ReviewRequests, |
| 431 | CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy, | 434 | CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy, |
| 432 | ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy} | 435 | ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy} |
| 433 | if withBody { | 436 | if withBody { |
| 434 | o.Body = m.Body | 437 | o.Body = m.Body |
| 435 | o.BodyFormat = m.BodyFormat | 438 | o.BodyFormat = m.BodyFormat |
| @@ -598,6 +601,9 @@ func runMRShow(c *Ctx, args []string) int { | |||
| 598 | if d.ClosedAt != "" { | 601 | if d.ClosedAt != "" { |
| 599 | fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy)) | 602 | fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy)) |
| 600 | } | 603 | } |
| 604 | if d.SupersededBy != 0 { | ||
| 605 | fmt.Fprintf(w, "superseded by: !%d\n", d.SupersededBy) | ||
| 606 | } | ||
| 601 | if d.Body != "" { | 607 | if d.Body != "" { |
| 602 | fmt.Fprintf(w, "\n%s\n", d.Body) | 608 | fmt.Fprintf(w, "\n%s\n", d.Body) |
| 603 | } | 609 | } |
| @@ -677,7 +683,7 @@ func runMRDiff(c *Ctx, args []string) int { | |||
| 677 | } | 683 | } |
| 678 | 684 | ||
| 679 | func runMREdit(c *Ctx, args []string) int { | 685 | func runMREdit(c *Ctx, args []string) int { |
| 680 | rest, title, body, format, code := editText(c, args, "mr") | 686 | rest, title, body, format, fl, code := editText(c, args, "mr", "--superseded-by") |
| 681 | if code >= 0 { | 687 | if code >= 0 { |
| 682 | return code | 688 | return code |
| 683 | } | 689 | } |
| @@ -691,9 +697,33 @@ func runMREdit(c *Ctx, args []string) int { | |||
| 691 | if code := authorOrWrite(c, repo, mr.Author, "edit this merge request"); code >= 0 { | 697 | if code := authorOrWrite(c, repo, mr.Author, "edit this merge request"); code >= 0 { |
| 692 | return code | 698 | return code |
| 693 | } | 699 | } |
| 700 | var clearSuperseded bool | ||
| 701 | var supersededBy int64 | ||
| 702 | if fl.Has("--superseded-by") { | ||
| 703 | if mr.State != "closed" { | ||
| 704 | return c.fail(protocol.ExitUsage, "only a closed merge request can be superseded") | ||
| 705 | } | ||
| 706 | if v := fl.Value("--superseded-by"); v == "none" { | ||
| 707 | clearSuperseded = true | ||
| 708 | } else { | ||
| 709 | supersededBy, code = resolveSupersededBy(c, repo, mr.Number, v) | ||
| 710 | if code >= 0 { | ||
| 711 | return code | ||
| 712 | } | ||
| 713 | } | ||
| 714 | } | ||
| 694 | if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil { | 715 | if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil { |
| 695 | return c.fail(protocol.ExitFailure, "%v", err) | 716 | return c.fail(protocol.ExitFailure, "%v", err) |
| 696 | } | 717 | } |
| 718 | if clearSuperseded { | ||
| 719 | if err := c.Store.SetSupersededBy(mr.ID, 0); err != nil { | ||
| 720 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 721 | } | ||
| 722 | } else if supersededBy != 0 { | ||
| 723 | if err := c.Store.SetSupersededBy(mr.ID, supersededBy); err != nil { | ||
| 724 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 725 | } | ||
| 726 | } | ||
| 697 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.edited", fmt.Sprintf(`{"number":%d}`, mr.Number)) | 727 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.edited", fmt.Sprintf(`{"number":%d}`, mr.Number)) |
| 698 | return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) { | 728 | return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) { |
| 699 | fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number) | 729 | fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number) |
| @@ -1499,14 +1529,19 @@ func setMRDraft(c *Ctx, args []string, draft bool) int { | |||
| 1499 | } | 1529 | } |
| 1500 | 1530 | ||
| 1501 | func runMRClose(c *Ctx, args []string) int { | 1531 | func runMRClose(c *Ctx, args []string) int { |
| 1502 | repo, mr, code := mrRef(c, args, policy.CanRead) | 1532 | f, err := parseFlags(args, flagSpec{Values: []string{"--by"}, MaxPos: 2, |
| 1533 | Usage: "mr close <owner/name> <n> [--by <m>]"}) | ||
| 1534 | if err != nil { | ||
| 1535 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 1536 | } | ||
| 1537 | repo, mr, code := mrRef(c, f.Pos, policy.CanRead) | ||
| 1503 | if code >= 0 { | 1538 | if code >= 0 { |
| 1504 | return code | 1539 | return code |
| 1505 | } | 1540 | } |
| 1506 | if code := refuseArchived(c, repo); code >= 0 { | 1541 | if code := refuseArchived(c, repo); code >= 0 { |
| 1507 | return code | 1542 | return code |
| 1508 | } | 1543 | } |
| 1509 | if len(args) != 2 { | 1544 | if len(f.Pos) != 2 { |
| 1510 | return c.usage() | 1545 | return c.usage() |
| 1511 | } | 1546 | } |
| 1512 | if code := authorOrWrite(c, repo, mr.Author, "close this merge request"); code >= 0 { | 1547 | if code := authorOrWrite(c, repo, mr.Author, "close this merge request"); code >= 0 { |
| @@ -1515,10 +1550,24 @@ func runMRClose(c *Ctx, args []string) int { | |||
| 1515 | if mr.State == "merged" || mr.State == "closed" { | 1550 | if mr.State == "merged" || mr.State == "closed" { |
| 1516 | return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State) | 1551 | return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State) |
| 1517 | } | 1552 | } |
| 1553 | var by int64 | ||
| 1554 | if f.Has("--by") { | ||
| 1555 | by, code = resolveSupersededBy(c, repo, mr.Number, f.Value("--by")) | ||
| 1556 | if code >= 0 { | ||
| 1557 | return code | ||
| 1558 | } | ||
| 1559 | } | ||
| 1518 | if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil { | 1560 | if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil { |
| 1519 | return c.fail(protocol.ExitFailure, "%v", err) | 1561 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1520 | } | 1562 | } |
| 1521 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", fmt.Sprintf(`{"number":%d}`, mr.Number)) | 1563 | eventData := fmt.Sprintf(`{"number":%d}`, mr.Number) |
| 1564 | if by != 0 { | ||
| 1565 | if err := c.Store.SetSupersededBy(mr.ID, by); err != nil { | ||
| 1566 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 1567 | } | ||
| 1568 | eventData = fmt.Sprintf(`{"number":%d,"by":%d}`, mr.Number, by) | ||
| 1569 | } | ||
| 1570 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData) | ||
| 1522 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | 1571 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 1523 | notify(c, parts, notice{repo: repo, kind: "mr", | 1572 | notify(c, parts, notice{repo: repo, kind: "mr", |
| 1524 | subject: mrSubject(repo, mr.Number, mr.Title), | 1573 | subject: mrSubject(repo, mr.Number, mr.Title), |
| @@ -1530,6 +1579,26 @@ func runMRClose(c *Ctx, args []string) int { | |||
| 1530 | }) | 1579 | }) |
| 1531 | } | 1580 | } |
| 1532 | 1581 | ||
| 1582 | // resolveSupersededBy validates a --superseded-by/--by value against the | ||
| 1583 | // merge request it would be set on: it must parse, name another merge | ||
| 1584 | // request in the same repository (never itself), and that request must | ||
| 1585 | // exist. -1 as the returned code means the value is good to use. | ||
| 1586 | func resolveSupersededBy(c *Ctx, repo store.Repo, number int64, v string) (int64, int) { | ||
| 1587 | m, err := strconv.ParseInt(v, 10, 64) | ||
| 1588 | if err != nil { | ||
| 1589 | return 0, c.fail(protocol.ExitUsage, "bad MR number %q", v) | ||
| 1590 | } | ||
| 1591 | if m == number { | ||
| 1592 | return 0, c.fail(protocol.ExitUsage, "a merge request cannot supersede itself") | ||
| 1593 | } | ||
| 1594 | if _, err := c.Store.MRByNumber(repo.ID, m); errors.Is(err, store.ErrNotFound) { | ||
| 1595 | return 0, c.fail(protocol.ExitNotFound, "no merge request !%d on %s", m, repo.Path()) | ||
| 1596 | } else if err != nil { | ||
| 1597 | return 0, c.fail(protocol.ExitFailure, "%v", err) | ||
| 1598 | } | ||
| 1599 | return m, -1 | ||
| 1600 | } | ||
| 1601 | |||
| 1533 | // reviewAction is what a review notification says it was. A verdict with | 1602 | // reviewAction is what a review notification says it was. A verdict with |
| 1534 | // a batch behind it is a different thing from a bare verdict, and the | 1603 | // a batch behind it is a different thing from a bare verdict, and the |
| 1535 | // person reading the mail is deciding whether to open it. | 1604 | // person reading the mail is deciding whether to open it. |
internal/control/mr_test.go added +179
| @@ -0,0 +1,179 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "strconv" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | |||
| 10 | "gitbay.org/gitbay/internal/protocol" | ||
| 11 | "gitbay.org/gitbay/internal/store" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // mrTestCtx runs control commands as owner against st, capturing output. | ||
| 15 | func mrTestCtx(st *store.Store, owner store.User) (*Ctx, *bytes.Buffer, *bytes.Buffer) { | ||
| 16 | out, errOut := &bytes.Buffer{}, &bytes.Buffer{} | ||
| 17 | c := &Ctx{User: owner, Scope: "full", Store: st, Stdout: out, Stderr: errOut} | ||
| 18 | return c, out, errOut | ||
| 19 | } | ||
| 20 | |||
| 21 | // twoMRTestRepo is a repository with two open merge requests, both | ||
| 22 | // authored by the returned owner, so authorOrWrite never gets in the way. | ||
| 23 | func twoMRTestRepo(t *testing.T) (*store.Store, store.Repo, store.User) { | ||
| 24 | t.Helper() | ||
| 25 | st, repo, uid := newQueueTestRepo(t) | ||
| 26 | owner := store.User{ID: uid, Username: "alice"} | ||
| 27 | if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature1", "main", "one", "", "abc111", "md", false); err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature2", "main", "two", "", "abc222", "md", false); err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | return st, repo, owner | ||
| 34 | } | ||
| 35 | |||
| 36 | func mrShowJSON(t *testing.T, st *store.Store, owner store.User, path string, n int64) mrOut { | ||
| 37 | t.Helper() | ||
| 38 | c, out, errOut := mrTestCtx(st, owner) | ||
| 39 | if code := Dispatch(c, []string{"mr", "show", path, strconv.FormatInt(n, 10), "--json"}); code != protocol.ExitOK { | ||
| 40 | t.Fatalf("mr show: exit %d, %s", code, errOut.String()) | ||
| 41 | } | ||
| 42 | var env struct { | ||
| 43 | Data mrOut `json:"data"` | ||
| 44 | } | ||
| 45 | if err := json.Unmarshal(out.Bytes(), &env); err != nil { | ||
| 46 | t.Fatalf("mr show JSON: %v\n%s", err, out.String()) | ||
| 47 | } | ||
| 48 | return env.Data | ||
| 49 | } | ||
| 50 | |||
| 51 | // eventDataFor pulls the most recent data_json for a kind, so a test can | ||
| 52 | // check what mr close recorded without a store accessor built just for it. | ||
| 53 | func eventDataFor(t *testing.T, st *store.Store, kind string) string { | ||
| 54 | t.Helper() | ||
| 55 | var data string | ||
| 56 | err := st.DB.QueryRow("SELECT data_json FROM events WHERE kind = ? ORDER BY id DESC LIMIT 1", kind).Scan(&data) | ||
| 57 | if err != nil { | ||
| 58 | t.Fatalf("event %s: %v", kind, err) | ||
| 59 | } | ||
| 60 | return data | ||
| 61 | } | ||
| 62 | |||
| 63 | // Closing a merge request can name the one that carries its change | ||
| 64 | // forward; mr show and the mr.closed event both then carry it (#223). | ||
| 65 | func TestMRCloseWithBy(t *testing.T) { | ||
| 66 | st, repo, owner := twoMRTestRepo(t) | ||
| 67 | c, _, errOut := mrTestCtx(st, owner) | ||
| 68 | if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1", "--by", "2"}); code != protocol.ExitOK { | ||
| 69 | t.Fatalf("mr close: exit %d, %s", code, errOut.String()) | ||
| 70 | } | ||
| 71 | got := mrShowJSON(t, st, owner, repo.Path(), 1) | ||
| 72 | if got.State != "closed" || got.SupersededBy != 2 { | ||
| 73 | t.Fatalf("mr show !1 = %+v, want closed superseded_by 2", got) | ||
| 74 | } | ||
| 75 | if data := eventDataFor(t, st, "mr.closed"); !strings.Contains(data, `"by":2`) { | ||
| 76 | t.Fatalf("mr.closed event = %s, want it to carry by:2", data) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | // mr close --by refuses a merge request naming itself. | ||
| 81 | func TestMRCloseBySelfRefused(t *testing.T) { | ||
| 82 | st, repo, owner := twoMRTestRepo(t) | ||
| 83 | c, _, errOut := mrTestCtx(st, owner) | ||
| 84 | code := Dispatch(c, []string{"mr", "close", repo.Path(), "1", "--by", "1"}) | ||
| 85 | if code != protocol.ExitUsage { | ||
| 86 | t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitUsage, errOut.String()) | ||
| 87 | } | ||
| 88 | if !strings.Contains(errOut.String(), "cannot supersede itself") { | ||
| 89 | t.Fatalf("stderr = %q, want it to say a merge request cannot supersede itself", errOut.String()) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | // mr close --by refuses a merge request number that does not exist in | ||
| 94 | // the repository. | ||
| 95 | func TestMRCloseByMissingRefused(t *testing.T) { | ||
| 96 | st, repo, owner := twoMRTestRepo(t) | ||
| 97 | c, _, errOut := mrTestCtx(st, owner) | ||
| 98 | code := Dispatch(c, []string{"mr", "close", repo.Path(), "1", "--by", "99"}) | ||
| 99 | if code != protocol.ExitNotFound { | ||
| 100 | t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitNotFound, errOut.String()) | ||
| 101 | } | ||
| 102 | if !strings.Contains(errOut.String(), "no merge request !99") { | ||
| 103 | t.Fatalf("stderr = %q, want it to name !99 as missing", errOut.String()) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | // mr edit --superseded-by sets and clears the field on a closed merge | ||
| 108 | // request. | ||
| 109 | func TestMREditSupersededBySetAndClear(t *testing.T) { | ||
| 110 | st, repo, owner := twoMRTestRepo(t) | ||
| 111 | c, _, errOut := mrTestCtx(st, owner) | ||
| 112 | if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1"}); code != protocol.ExitOK { | ||
| 113 | t.Fatalf("mr close: exit %d, %s", code, errOut.String()) | ||
| 114 | } | ||
| 115 | c, _, errOut = mrTestCtx(st, owner) | ||
| 116 | if code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "2"}); code != protocol.ExitOK { | ||
| 117 | t.Fatalf("mr edit --superseded-by 2: exit %d, %s", code, errOut.String()) | ||
| 118 | } | ||
| 119 | if got := mrShowJSON(t, st, owner, repo.Path(), 1); got.SupersededBy != 2 { | ||
| 120 | t.Fatalf("SupersededBy = %d, want 2", got.SupersededBy) | ||
| 121 | } | ||
| 122 | c, _, errOut = mrTestCtx(st, owner) | ||
| 123 | if code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "none"}); code != protocol.ExitOK { | ||
| 124 | t.Fatalf("mr edit --superseded-by none: exit %d, %s", code, errOut.String()) | ||
| 125 | } | ||
| 126 | if got := mrShowJSON(t, st, owner, repo.Path(), 1); got.SupersededBy != 0 { | ||
| 127 | t.Fatalf("SupersededBy after clear = %d, want 0", got.SupersededBy) | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | // mr edit --superseded-by refuses a self-reference the same way mr close | ||
| 132 | // --by does. | ||
| 133 | func TestMREditSupersededBySelfRefused(t *testing.T) { | ||
| 134 | st, repo, owner := twoMRTestRepo(t) | ||
| 135 | c, _, errOut := mrTestCtx(st, owner) | ||
| 136 | if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1"}); code != protocol.ExitOK { | ||
| 137 | t.Fatalf("mr close: exit %d, %s", code, errOut.String()) | ||
| 138 | } | ||
| 139 | c, _, errOut = mrTestCtx(st, owner) | ||
| 140 | code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "1"}) | ||
| 141 | if code != protocol.ExitUsage { | ||
| 142 | t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitUsage, errOut.String()) | ||
| 143 | } | ||
| 144 | if !strings.Contains(errOut.String(), "cannot supersede itself") { | ||
| 145 | t.Fatalf("stderr = %q, want it to say a merge request cannot supersede itself", errOut.String()) | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | // mr edit --superseded-by refuses a merge request number that does not | ||
| 150 | // exist in the repository. | ||
| 151 | func TestMREditSupersededByMissingRefused(t *testing.T) { | ||
| 152 | st, repo, owner := twoMRTestRepo(t) | ||
| 153 | c, _, errOut := mrTestCtx(st, owner) | ||
| 154 | if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1"}); code != protocol.ExitOK { | ||
| 155 | t.Fatalf("mr close: exit %d, %s", code, errOut.String()) | ||
| 156 | } | ||
| 157 | c, _, errOut = mrTestCtx(st, owner) | ||
| 158 | code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "99"}) | ||
| 159 | if code != protocol.ExitNotFound { | ||
| 160 | t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitNotFound, errOut.String()) | ||
| 161 | } | ||
| 162 | if !strings.Contains(errOut.String(), "no merge request !99") { | ||
| 163 | t.Fatalf("stderr = %q, want it to name !99 as missing", errOut.String()) | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | // mr edit --superseded-by refuses an open merge request: only a closed | ||
| 168 | // one can be superseded. | ||
| 169 | func TestMREditSupersededByOnOpenMRRefused(t *testing.T) { | ||
| 170 | st, repo, owner := twoMRTestRepo(t) | ||
| 171 | c, _, errOut := mrTestCtx(st, owner) | ||
| 172 | code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "2"}) | ||
| 173 | if code != protocol.ExitUsage { | ||
| 174 | t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitUsage, errOut.String()) | ||
| 175 | } | ||
| 176 | if !strings.Contains(errOut.String(), "only a closed merge request can be superseded") { | ||
| 177 | t.Fatalf("stderr = %q, want the closed-only refusal", errOut.String()) | ||
| 178 | } | ||
| 179 | } | ||
internal/httpd/mractions.go +9 −1
| @@ -76,7 +76,15 @@ func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.U | |||
| 76 | } | 76 | } |
| 77 | 77 | ||
| 78 | func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 78 | func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 79 | _, msg, code := s.runControlCode(u, mrArgs(r, "close")) | 79 | args := []string{} |
| 80 | if by := strings.TrimSpace(r.FormValue("by")); by != "" { | ||
| 81 | if _, err := strconv.ParseInt(by, 10, 64); err != nil { | ||
| 82 | s.mrRedirect(w, r, "the superseding request is a number") | ||
| 83 | return | ||
| 84 | } | ||
| 85 | args = append(args, "--by", by) | ||
| 86 | } | ||
| 87 | _, msg, code := s.runControlCode(u, mrArgs(r, "close", args...)) | ||
| 80 | s.done(w, r, code, msg, s.mrRedirect) | 88 | s.done(w, r, code, msg, s.mrRedirect) |
| 81 | } | 89 | } |
| 82 | 90 | ||
internal/httpd/web.go +5 −1
| @@ -1957,6 +1957,9 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1957 | stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef) | 1957 | stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef) |
| 1958 | } | 1958 | } |
| 1959 | } | 1959 | } |
| 1960 | // The merge requests this one superseded when it was closed, so the | ||
| 1961 | // page it points to can also say what it supersedes. | ||
| 1962 | supersedes, _ := s.st.MRsSuperseding(p.Repo.ID, m.Number) | ||
| 1960 | s.render(w, "mr.html", struct { | 1963 | s.render(w, "mr.html", struct { |
| 1961 | repoPage | 1964 | repoPage |
| 1962 | MR store.MR | 1965 | MR store.MR |
| @@ -1980,6 +1983,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1980 | DetachedThreads []diffThread | 1983 | DetachedThreads []diffThread |
| 1981 | StackedOn *store.MR | 1984 | StackedOn *store.MR |
| 1982 | Stacked []store.MR | 1985 | Stacked []store.MR |
| 1986 | Supersedes []store.MR | ||
| 1983 | Gates *control.GatesOut | 1987 | Gates *control.GatesOut |
| 1984 | SourceGone bool | 1988 | SourceGone bool |
| 1985 | HeadMerged bool | 1989 | HeadMerged bool |
| @@ -1987,7 +1991,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1987 | Base string | 1991 | Base string |
| 1988 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), | 1992 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), |
| 1989 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), | 1993 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), |
| 1990 | canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates, | 1994 | canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates, |
| 1991 | sourceGone(p, m), headMerged, headPruned, base}) | 1995 | sourceGone(p, m), headMerged, headPruned, base}) |
| 1992 | } | 1996 | } |
| 1993 | 1997 | ||
internal/store/migrations/0055_mr_superseded_by.down.sql added +1
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE merge_requests DROP COLUMN superseded_by; | ||
internal/store/migrations/0055_mr_superseded_by.up.sql added +3
| @@ -0,0 +1,3 @@ | |||
| 1 | -- Which merge request, by number within the same repository, a closed | ||
| 2 | -- merge request was closed in favour of. NULL means none (#223). | ||
| 3 | ALTER TABLE merge_requests ADD COLUMN superseded_by INTEGER; | ||
internal/store/mrs.go +38 −4
| @@ -29,8 +29,11 @@ type MR struct { | |||
| 29 | MergedBy string // "" when unknown (imports) or the account is gone | 29 | MergedBy string // "" when unknown (imports) or the account is gone |
| 30 | ClosedAt string // "" unless closed without merging | 30 | ClosedAt string // "" unless closed without merging |
| 31 | ClosedBy string | 31 | ClosedBy string |
| 32 | CreatedAt string | 32 | // SupersededBy is the number, within this repository, of the merge |
| 33 | UpdatedAt string | 33 | // request this one was closed in favour of. 0 means none. |
| 34 | SupersededBy int64 | ||
| 35 | CreatedAt string | ||
| 36 | UpdatedAt string | ||
| 34 | // ReviewRequests is who has been asked, directly, for a review — the | 37 | // ReviewRequests is who has been asked, directly, for a review — the |
| 35 | // mr review request counterpart of Issue.Assignees. | 38 | // mr review request counterpart of Issue.Assignees. |
| 36 | ReviewRequests []string | 39 | ReviewRequests []string |
| @@ -92,7 +95,7 @@ const mrSelect = ` | |||
| 92 | m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, | 95 | m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, |
| 93 | COALESCE(ms.title, ''), m.head_sha, | 96 | COALESCE(ms.title, ''), m.head_sha, |
| 94 | m.merged_base, m.merged_at, COALESCE(mu.username, ''), | 97 | m.merged_base, m.merged_at, COALESCE(mu.username, ''), |
| 95 | m.closed_at, COALESCE(cu.username, ''), m.created_at, m.updated_at | 98 | m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at |
| 96 | FROM merge_requests m | 99 | FROM merge_requests m |
| 97 | JOIN users u ON u.id = m.author_id | 100 | JOIN users u ON u.id = m.author_id |
| 98 | LEFT JOIN users mu ON mu.id = m.merged_by | 101 | LEFT JOIN users mu ON mu.id = m.merged_by |
| @@ -106,7 +109,7 @@ func scanMR(row interface{ Scan(...any) error }) (MR, error) { | |||
| 106 | var m MR | 109 | var m MR |
| 107 | err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, | 110 | err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, |
| 108 | &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase, | 111 | &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase, |
| 109 | &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.CreatedAt, &m.UpdatedAt) | 112 | &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt) |
| 110 | return m, err | 113 | return m, err |
| 111 | } | 114 | } |
| 112 | 115 | ||
| @@ -265,6 +268,37 @@ func (s *Store) MarkClosed(mrID, actorID int64, at string) error { | |||
| 265 | return err | 268 | return err |
| 266 | } | 269 | } |
| 267 | 270 | ||
| 271 | // SetSupersededBy records which merge request, by number within the same | ||
| 272 | // repository, this one was closed in favour of. n of 0 clears it. | ||
| 273 | func (s *Store) SetSupersededBy(mrID, n int64) error { | ||
| 274 | var v any | ||
| 275 | if n != 0 { | ||
| 276 | v = n | ||
| 277 | } | ||
| 278 | _, err := s.DB.Exec("UPDATE merge_requests SET superseded_by = ? WHERE id = ?", v, mrID) | ||
| 279 | return err | ||
| 280 | } | ||
| 281 | |||
| 282 | // MRsSuperseding returns the merge requests in a repository whose | ||
| 283 | // superseded_by names number, oldest first — the reverse of | ||
| 284 | // MR.SupersededBy. | ||
| 285 | func (s *Store) MRsSuperseding(repoID, number int64) ([]MR, error) { | ||
| 286 | rows, err := s.DB.Query(mrSelect+" WHERE m.repo_id = ? AND m.superseded_by = ? ORDER BY m.number ASC", repoID, number) | ||
| 287 | if err != nil { | ||
| 288 | return nil, err | ||
| 289 | } | ||
| 290 | defer rows.Close() | ||
| 291 | var out []MR | ||
| 292 | for rows.Next() { | ||
| 293 | m, err := scanMR(rows) | ||
| 294 | if err != nil { | ||
| 295 | return nil, err | ||
| 296 | } | ||
| 297 | out = append(out, m) | ||
| 298 | } | ||
| 299 | return out, rows.Err() | ||
| 300 | } | ||
| 301 | |||
| 268 | // SetMRState moves an MR between states that carry no resolution stamp. | 302 | // SetMRState moves an MR between states that carry no resolution stamp. |
| 269 | // Returning to open (a source branch that came back) clears one. | 303 | // Returning to open (a source branch that came back) clears one. |
| 270 | func (s *Store) SetMRState(mrID int64, state string) error { | 304 | func (s *Store) SetMRState(mrID int64, state string) error { |
internal/store/mrs_test.go +42
| @@ -59,6 +59,48 @@ func TestResolutionStamps(t *testing.T) { | |||
| 59 | } | 59 | } |
| 60 | } | 60 | } |
| 61 | 61 | ||
| 62 | // A merge request closed without merging can record the request that | ||
| 63 | // carried its change forward; MRsSuperseding is the reverse lookup, and | ||
| 64 | // 0 clears the field (#223). | ||
| 65 | func TestSupersededBy(t *testing.T) { | ||
| 66 | s, repoID, uid := mrFixture(t) | ||
| 67 | if _, err := s.CreateMR(repoID, uid, repoID, "feature2", "main", "t2", "", "def456", "md", false); err != nil { | ||
| 68 | t.Fatal(err) | ||
| 69 | } | ||
| 70 | mr1, _ := s.MRByNumber(repoID, 1) | ||
| 71 | if err := s.MarkClosed(mr1.ID, uid, ""); err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | if err := s.SetSupersededBy(mr1.ID, 2); err != nil { | ||
| 75 | t.Fatal(err) | ||
| 76 | } | ||
| 77 | mr1, _ = s.MRByNumber(repoID, 1) | ||
| 78 | if mr1.SupersededBy != 2 { | ||
| 79 | t.Fatalf("SupersededBy = %d, want 2", mr1.SupersededBy) | ||
| 80 | } | ||
| 81 | superseding, err := s.MRsSuperseding(repoID, 2) | ||
| 82 | if err != nil { | ||
| 83 | t.Fatal(err) | ||
| 84 | } | ||
| 85 | if len(superseding) != 1 || superseding[0].Number != 1 { | ||
| 86 | t.Fatalf("MRsSuperseding(repoID, 2) = %+v", superseding) | ||
| 87 | } | ||
| 88 | if err := s.SetSupersededBy(mr1.ID, 0); err != nil { | ||
| 89 | t.Fatal(err) | ||
| 90 | } | ||
| 91 | mr1, _ = s.MRByNumber(repoID, 1) | ||
| 92 | if mr1.SupersededBy != 0 { | ||
| 93 | t.Fatalf("SupersededBy after clear = %d, want 0", mr1.SupersededBy) | ||
| 94 | } | ||
| 95 | superseding, err = s.MRsSuperseding(repoID, 2) | ||
| 96 | if err != nil { | ||
| 97 | t.Fatal(err) | ||
| 98 | } | ||
| 99 | if len(superseding) != 0 { | ||
| 100 | t.Fatalf("MRsSuperseding(repoID, 2) after clear = %+v", superseding) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 62 | // An import carries the upstream time but no local account for the actor. | 104 | // An import carries the upstream time but no local account for the actor. |
| 63 | func TestResolutionStampImported(t *testing.T) { | 105 | func TestResolutionStampImported(t *testing.T) { |
| 64 | s, repoID, _ := mrFixture(t) | 106 | s, repoID, _ := mrFixture(t) |
internal/web/templates/mr.html +4 −1
| @@ -5,12 +5,13 @@ | |||
| 5 | <h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> | 5 | <h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> |
| 6 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{if eq .MR.State "source_gone"}}source gone{{else}}{{.MR.State}}{{end}}</span>{{if .MR.Draft}} <span class="chip chip-neutral">draft</span>{{end}} | 6 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{if eq .MR.State "source_gone"}}source gone{{else}}{{.MR.State}}{{end}}</span>{{if .MR.Draft}} <span class="chip chip-neutral">draft</span>{{end}} |
| 7 | {{if and (eq .MR.State "merged") .MR.MergedBy}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a>{{with .MR.MergedAt}} on {{when .}}{{end}} | 7 | {{if and (eq .MR.State "merged") .MR.MergedBy}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a>{{with .MR.MergedAt}} on {{when .}}{{end}} |
| 8 | {{else if and (eq .MR.State "closed") .MR.ClosedBy}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a>{{with .MR.ClosedAt}} on {{when .}}{{end}} | 8 | {{else if and (eq .MR.State "closed") .MR.ClosedBy}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a>{{with .MR.ClosedAt}} on {{when .}}{{end}}{{if .MR.SupersededBy}}, in favour of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.SupersededBy}}">!{{.MR.SupersededBy}}</a>{{end}} |
| 9 | {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}} | 9 | {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}} |
| 10 | {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}} | 10 | {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}} |
| 11 | · <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> | 11 | · <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> |
| 12 | {{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}} | 12 | {{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}} |
| 13 | {{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}} | 13 | {{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}} |
| 14 | {{with field . "Supersedes"}}<p class="meta">supersedes {{range $i, $m := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$m.Number}}">!{{$m.Number}}</a>{{end}}</p>{{end}} | ||
| 14 | 15 | ||
| 15 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | 16 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 16 | 17 | ||
| @@ -103,6 +104,8 @@ | |||
| 103 | <button type="submit">Merge</button> | 104 | <button type="submit">Merge</button> |
| 104 | </form> | 105 | </form> |
| 105 | <form method="post" action="{{$base}}/close" class="actions"> | 106 | <form method="post" action="{{$base}}/close" class="actions"> |
| 107 | <label class="vh" for="by">Closed in favour of</label> | ||
| 108 | <input type="text" id="by" name="by" inputmode="numeric" size="4" placeholder="!N"> | ||
| 106 | <button type="submit" class="danger">Close without merging</button> | 109 | <button type="submit" class="danger">Close without merging</button> |
| 107 | </form> | 110 | </form> |
| 108 | <form method="post" action="{{$base}}/draft" class="actions"> | 111 | <form method="post" action="{{$base}}/draft" class="actions"> |