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
46 changes: 45 additions & 1 deletion src/ghstack/test_prelude.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import argparse
import atexit
import contextlib
Expand Down 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 @@
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,36 @@
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
import ghstack.merge_rules

Check warning on line 5 in test/land/dry_run.py.test

View workflow job for this annotation

GitHub Actions / lint (3.13, ubuntu-latest)

FLAKE8 F401

'ghstack.merge_rules' imported but unused See https://www.flake8rules.com/rules/F401.html
from ghstack.github_fake import GitHubNumber, PullRequestReview, CheckRun

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")

Check warning on line 36 in test/land/dry_run.py.test

View workflow job for this annotation

GitHub Actions / lint (3.13, ubuntu-latest)

FLAKE8 W293

blank line contains whitespace See https://www.flake8rules.com/rules/W293.html
# Dry run should NOT land the commit
gh_land(pr_url, validate_rules=True, dry_run=True, rules_file=rules_file)

Check warning on line 39 in test/land/dry_run.py.test

View workflow job for this annotation

GitHub Actions / lint (3.13, ubuntu-latest)

FLAKE8 W293

blank line contains whitespace See https://www.flake8rules.com/rules/W293.html
# Verify master is unchanged (no commit was landed)
final_log = get_upstream_sh().git("log", "--oneline", "master")
assert_eq(initial_log, final_log)

Check warning on line 43 in test/land/dry_run.py.test

View workflow job for this annotation

GitHub Actions / lint (3.13, ubuntu-latest)

FLAKE8 W293

blank line contains whitespace See https://www.flake8rules.com/rules/W293.html
# The PR should still be open
assert_eq(pr.closed, False)
finally:
os.unlink(rules_file)

ok()
46 changes: 46 additions & 0 deletions test/land/validate_rules_fail.py.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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
import ghstack.merge_rules

Check warning on line 5 in test/land/validate_rules_pass.py.test

View workflow job for this annotation

GitHub Actions / lint (3.13, ubuntu-latest)

FLAKE8 F401

'ghstack.merge_rules' imported but unused See https://www.flake8rules.com/rules/F401.html
from ghstack.github_fake import GitHubNumber, PullRequestReview, CheckRun

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)

Check warning on line 36 in test/land/validate_rules_pass.py.test

View workflow job for this annotation

GitHub Actions / lint (3.13, ubuntu-latest)

FLAKE8 W293

blank line contains whitespace See https://www.flake8rules.com/rules/W293.html
# 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 PullRequestReview, GitHubNumber

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
Loading