Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion src/ghstack/test_prelude.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
"get_github",
"get_pr_reviewers",
"get_pr_labels",
"set_pr_files",
"set_pr_reviews",
"set_pr_check_runs",
"tick",
"captured_output",
]
Expand Down Expand Up @@ -225,14 +228,25 @@ def gh_submit(
return r


def gh_land(pull_request: str) -> None:
def gh_land(
pull_request: str,
*,
validate_rules: bool = False,
dry_run: bool = False,
comment_on_failure: bool = False,
rules_file: Optional[str] = None,
) -> None:
self = CTX
return ghstack.land.main(
remote_name="origin",
pull_request=pull_request,
github=self.github,
sh=self.sh,
github_url="github.com",
validate_rules=validate_rules,
dry_run=dry_run,
comment_on_failure=comment_on_failure,
rules_file=rules_file,
)


Expand Down Expand Up @@ -412,6 +426,37 @@ def get_pr_labels(pr_number: int) -> List[str]:
return pr.labels


def set_pr_files(pr_number: int, files: List[str]) -> None:
"""Set the list of changed files for a PR."""
github = get_github()
repo = github.state.repository("pytorch", "pytorch")
pr = github.state.pull_request(repo, ghstack.github_fake.GitHubNumber(pr_number))
pr.files = files


def set_pr_reviews(pr_number: int, reviews: List[Tuple[str, str]]) -> None:
"""Set reviews for a PR. reviews is list of (user, state) tuples."""
github = get_github()
repo = github.state.repository("pytorch", "pytorch")
pr = github.state.pull_request(repo, ghstack.github_fake.GitHubNumber(pr_number))
pr.reviews = [
ghstack.github_fake.PullRequestReview(user=u, state=s) for u, s in reviews
]


def set_pr_check_runs(
pr_number: int, checks: List[Tuple[str, str, Optional[str]]]
) -> None:
"""Set check runs for a PR. checks is list of (name, status, conclusion) tuples."""
github = get_github()
repo = github.state.repository("pytorch", "pytorch")
pr = github.state.pull_request(repo, ghstack.github_fake.GitHubNumber(pr_number))
pr.check_runs = [
ghstack.github_fake.CheckRun(name=n, status=s, conclusion=c)
for n, s, c in checks
]


def assert_eq(a: Any, b: Any) -> None:
assert a == b, f"{a} != {b}"

Expand Down
49 changes: 49 additions & 0 deletions test/land/dry_run.py.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from ghstack.test_prelude import *

import os
import tempfile

from ghstack.github_fake import CheckRun, GitHubNumber, PullRequestReview

init_test()
commit("A")
(diff,) = gh_submit("Initial 1")
pr_url = diff.pr_url

# Set up PR with files, approvals, and passing checks
github = get_github()
repo = github.state.repository("pytorch", "pytorch")
pr = github.state.pull_request(repo, GitHubNumber(500))
pr.files = ["src/core/module.py"]
pr.reviews = [PullRequestReview(user="maintainer1", state="APPROVED")]
pr.check_runs = [CheckRun(name="CI", status="completed", conclusion="success")]

# Create a temporary rules file
rules_content = """
- name: core-changes
patterns: ["src/core/*"]
approved_by: ["maintainer1"]
mandatory_checks_name: ["CI"]
"""

with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(rules_content)
rules_file = f.name

try:
# Get the initial state of master
initial_log = get_upstream_sh().git("log", "--oneline", "master")

# Dry run should NOT land the commit
gh_land(pr_url, validate_rules=True, dry_run=True, rules_file=rules_file)

# Verify master is unchanged (no commit was landed)
final_log = get_upstream_sh().git("log", "--oneline", "master")
assert_eq(initial_log, final_log)

# The PR should still be open
assert_eq(pr.closed, False)
finally:
os.unlink(rules_file)

ok()
47 changes: 47 additions & 0 deletions test/land/validate_rules_fail.py.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from ghstack.test_prelude import *

import os
import tempfile

import ghstack.merge_rules
from ghstack.github_fake import GitHubNumber

init_test()
commit("A")
(diff,) = gh_submit("Initial 1")
pr_url = diff.pr_url

# Set up PR with files but no approvals
github = get_github()
repo = github.state.repository("pytorch", "pytorch")
pr = github.state.pull_request(repo, GitHubNumber(500))
pr.files = ["src/core/module.py"]
pr.reviews = [] # No reviews

# Create a temporary rules file
rules_content = """
- name: core-changes
patterns: ["src/core/*"]
approved_by: ["maintainer1"]
mandatory_checks_name: []
"""

with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(rules_content)
rules_file = f.name

try:
# Attempt to land with validation - should raise MergeValidationError
assert_expected_raises_inline(
ghstack.merge_rules.MergeValidationError,
lambda: gh_land(pr_url, validate_rules=True, rules_file=rules_file),
"""\
Merge validation failed for PR #500
Rule: core-changes
Errors:
- Missing required approval from: maintainer1""",
)
finally:
os.unlink(rules_file)

ok()
47 changes: 47 additions & 0 deletions test/land/validate_rules_pass.py.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from ghstack.test_prelude import *

import os
import tempfile

from ghstack.github_fake import CheckRun, GitHubNumber, PullRequestReview

init_test()
commit("A")
(diff,) = gh_submit("Initial 1")
pr_url = diff.pr_url

# Set up PR with files, approvals, and passing checks
github = get_github()
repo = github.state.repository("pytorch", "pytorch")
pr = github.state.pull_request(repo, GitHubNumber(500))
pr.files = ["src/core/module.py"]
pr.reviews = [PullRequestReview(user="maintainer1", state="APPROVED")]
pr.check_runs = [CheckRun(name="CI", status="completed", conclusion="success")]

# Create a temporary rules file
rules_content = """
- name: core-changes
patterns: ["src/core/*"]
approved_by: ["maintainer1"]
mandatory_checks_name: ["CI"]
"""

with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(rules_content)
rules_file = f.name

try:
# Land with validation - should succeed
gh_land(pr_url, validate_rules=True, rules_file=rules_file)

# Verify the commit was landed
assert_expected_inline(
get_upstream_sh().git("log", "--oneline", "master"),
"""\
d518c9f Commit A (#500)
dc8bfe4 Initial commit""",
)
finally:
os.unlink(rules_file)

ok()
93 changes: 93 additions & 0 deletions test/merge_rules/approval_validation.py.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from ghstack.test_prelude import *

import ghstack.merge_rules
from ghstack.github_fake import GitHubNumber, PullRequestReview

init_test()
commit("A")
(A,) = gh_submit("Initial 1")

# Set up PR with files and NO approval
github = get_github()
repo = github.state.repository("pytorch", "pytorch")
pr = github.state.pull_request(repo, GitHubNumber(500))
pr.files = ["src/core/module.py"]
pr.reviews = [] # No reviews yet

rules = [
ghstack.merge_rules.MergeRule(
name="core",
patterns=["src/core/*"],
approved_by=["maintainer1"],
mandatory_checks_name=[],
)
]

validator = ghstack.merge_rules.MergeValidator(github, "pytorch", "pytorch")
result = validator.validate_pr(500, rules)

# Should fail - missing approval
assert_eq(result.valid, False)
assert "Missing required approval" in result.errors[0]

# Add approval from wrong user - should still fail
pr.reviews = [PullRequestReview(user="random_user", state="APPROVED")]
result = validator.validate_pr(500, rules)
assert_eq(result.valid, False)

# Add approval from required user - should pass
pr.reviews = [PullRequestReview(user="maintainer1", state="APPROVED")]
result = validator.validate_pr(500, rules)
assert_eq(result.valid, True)

# Test multiple required approvers - any one should work
rules = [
ghstack.merge_rules.MergeRule(
name="core",
patterns=["src/core/*"],
approved_by=["maintainer1", "maintainer2"],
mandatory_checks_name=[],
)
]

pr.reviews = [PullRequestReview(user="maintainer2", state="APPROVED")]
result = validator.validate_pr(500, rules)
assert_eq(result.valid, True)

# Test that CHANGES_REQUESTED doesn't count as approval
pr.reviews = [PullRequestReview(user="maintainer1", state="CHANGES_REQUESTED")]
result = validator.validate_pr(500, rules)
assert_eq(result.valid, False)

# Test that latest review state wins
pr.reviews = [
PullRequestReview(user="maintainer1", state="APPROVED"),
PullRequestReview(user="maintainer1", state="CHANGES_REQUESTED"),
]
result = validator.validate_pr(500, rules)
assert_eq(result.valid, False)

# Re-approval after changes_requested should work
pr.reviews = [
PullRequestReview(user="maintainer1", state="CHANGES_REQUESTED"),
PullRequestReview(user="maintainer1", state="APPROVED"),
]
result = validator.validate_pr(500, rules)
assert_eq(result.valid, True)

# Test "any" special approver - any approval should work
rules = [
ghstack.merge_rules.MergeRule(
name="any-approval",
patterns=["src/*"],
approved_by=["any"],
mandatory_checks_name=[],
)
]

pr.files = ["src/module.py"]
pr.reviews = [PullRequestReview(user="anyone", state="APPROVED")]
result = validator.validate_pr(500, rules)
assert_eq(result.valid, True)

ok()
Loading