pkg-30922 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pkg_30922/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """
2
+ Main package entry point.
3
+
4
+ This package provides automated pull request merging functionality for GitHub,
5
+ including repository discovery, PR filtering, merge readiness evaluation, and
6
+ batch merging operations.
7
+
8
+ Submodules:
9
+ - config: Configuration loading and validation
10
+ - services.gh_ci: Merge readiness evaluation with CI and review checks
11
+ - services.gh_merge: PR merge execution
12
+ - services.gh_pr: Open PR retrieval and filtering
13
+ - services.gh_repo: Repository discovery
14
+ - services.gh_user: GitHub authentication and user info
15
+ """
16
+
17
+ from .config import Config, load_config, merge_config_with_cli
18
+ from .services.gh_ci import get_merge_readiness
19
+ from .services.gh_merge import put_merge_pr
20
+ from .services.gh_pr import get_open_prs
21
+ from .services.gh_repo import get_repos_from_all, get_repos_from_owner
22
+ from .services.gh_user import get_auth, get_token_user_info
23
+
24
+ __version__ = "1.0.0"
25
+
26
+ __all__ = (
27
+ "Config",
28
+ "load_config",
29
+ "merge_config_with_cli",
30
+ "get_auth",
31
+ "get_merge_readiness",
32
+ "put_merge_pr",
33
+ "get_open_prs",
34
+ "get_repos_from_all",
35
+ "get_repos_from_owner",
36
+ "get_token_user_info",
37
+ )
pkg_30922/cli.py ADDED
@@ -0,0 +1,258 @@
1
+ """
2
+ CLI tool to automate pull request merging.
3
+
4
+ This module provides functionality to:
5
+ - Get a list of repositories across organizations and users
6
+ - Filter repositories with open pull requests
7
+ - Filter pull requests by label, base branch, title prefix, and owner
8
+ - Evaluate merge readiness based on CI status and code review approvals
9
+ - Execute batch merge operations across multiple repositories
10
+
11
+ Key Features:
12
+ - Concurrent processing using asyncio for efficiency
13
+ - Support for dry-run mode to preview merges without executing
14
+ - Filtering by multiple criteria (labels, branch, prefix, owner)
15
+ - Merge method selection (merge, rebase, squash)
16
+ - Bypass review count option to merge without sufficient approvals
17
+ """
18
+
19
+ import asyncio
20
+ import os
21
+ import sys
22
+ from typing import Optional
23
+
24
+ import asyncclick as click
25
+
26
+ from pkg_30922 import (
27
+ __version__,
28
+ get_auth,
29
+ get_merge_readiness,
30
+ get_open_prs,
31
+ get_repos_from_all,
32
+ get_repos_from_owner,
33
+ get_token_user_info,
34
+ put_merge_pr,
35
+ )
36
+ from pkg_30922.config import Config, load_config, merge_config_with_cli
37
+
38
+ ALLOWED_REPO_TYPES = ["all", "private", "public"]
39
+ ALLOWED_MERGE_METHODS = ["merge", "rebase", "squash"]
40
+
41
+
42
+ class CustomError(Exception):
43
+ pass
44
+
45
+
46
+ def check_owner(owner: str, orgs: list[str], login: str) -> None:
47
+ """
48
+ Validate that the owner is valid for the authenticated token.
49
+
50
+ Args:
51
+ owner: The owner to validate (username or organization name).
52
+ orgs: List of organizations the token has access to.
53
+ login: The authenticated user's login name.
54
+
55
+ Raises:
56
+ CustomError: If owner is not in orgs and doesn't match the token owner.
57
+ """
58
+ if owner:
59
+ if owner not in orgs and owner != login:
60
+ raise CustomError("CLI option '--owner' is invalid (mismatch what the token suggests)")
61
+
62
+
63
+ def find_config_file() -> Optional[str]:
64
+ """
65
+ Find configuration file for GitHub Actions users.
66
+
67
+ Searches for config file at the default location (./config/default.yaml).
68
+
69
+ Returns:
70
+ Path to config file if found, None otherwise.
71
+ """
72
+ config_locations = ["./config/default.yaml"]
73
+
74
+ for location in config_locations:
75
+ if os.path.exists(location):
76
+ return location
77
+
78
+ return None
79
+
80
+
81
+ def check_repo_type(repo_type: str) -> None:
82
+ """
83
+ Validate the repository type option.
84
+
85
+ Args:
86
+ repo_type: The repository type to validate.
87
+
88
+ Raises:
89
+ CustomError: If repo_type is not one of 'all', 'private', or 'public'.
90
+ """
91
+ if repo_type:
92
+ if not repo_type.strip().lower() in ALLOWED_REPO_TYPES:
93
+ raise CustomError("CLI option '--repo-type' requires all or private or public")
94
+
95
+
96
+ def check_merge_method(merge_method: str) -> None:
97
+ """
98
+ Validate the merge method option.
99
+
100
+ Args:
101
+ merge_method: The merge method to validate.
102
+
103
+ Raises:
104
+ CustomError: If merge_method is not one of 'merge', 'rebase', or 'squash'.
105
+ """
106
+ if merge_method:
107
+ if not merge_method.strip().lower() in ALLOWED_MERGE_METHODS:
108
+ raise CustomError("CLI option '--merge-method' requires merge, rebase, or squash")
109
+
110
+
111
+ def print_summary(number_of_open_prs: int, number_of_mergeable_prs: int, number_of_merged_prs: int) -> None:
112
+ """
113
+ Print summary
114
+
115
+ Args:
116
+ number_of_open_prs: Number of open pull requests
117
+ number_of_mergeable_prs: Number of mergeable pull requests
118
+ number_of_merged_prs: Number of merged pull requests
119
+ """
120
+ dict_summary = {}
121
+ dict_summary["open-prs"] = number_of_open_prs
122
+ dict_summary["mergeable-prs"] = number_of_mergeable_prs
123
+ dict_summary["merged-prs"] = number_of_merged_prs
124
+ print()
125
+ print(f"🎉 Merge Pull Request Task Info. :: {dict_summary}")
126
+
127
+
128
+ @click.command()
129
+ @click.option("--base-branch", type=str, default="main", help="Restrict to a particular base-branch [case-sensitive]")
130
+ @click.option("--bypass-review-count", type=bool, default=False, help="Bypass required review count (default: false)")
131
+ @click.option("--exclude-labels", type=str, default="", help="Restrict to particular labels to excldue")
132
+ @click.option("--include-labels", type=str, default="", help="Restrict to particular labels to incldue")
133
+ @click.option("--merge-method", type=str, default="merge", help="merge|rebase|squash (default: merge)")
134
+ @click.option("--owner", type=str, default="", help="Restrict to a particular user/org")
135
+ @click.option("--prefix", type=str, default="", help="Restrict to a PR title prefix")
136
+ @click.option("--repo-type", type=str, default="all", help="all|private|public (default: all)")
137
+ @click.option("--dry-run", type=bool, default=True, help="Show PR ready for merge (default: true)")
138
+ @click.version_option(version=__version__)
139
+ async def main(
140
+ base_branch: str,
141
+ dry_run: bool,
142
+ exclude_labels: str,
143
+ include_labels: str,
144
+ merge_method: str,
145
+ bypass_review_count: bool,
146
+ owner: str,
147
+ prefix: str,
148
+ repo_type: str,
149
+ ) -> None:
150
+ """
151
+ Main CLI entry point to merge pull requests
152
+
153
+ \f
154
+ Executes the complete workflow:
155
+ 1. Load configuration (YAML file or defaults)
156
+ 2. Validate and merge CLI arguments with config
157
+ 3. Authenticate with GitHub API
158
+ 4. Discover repositories
159
+ 5. Fetch and filter open pull requests
160
+ 6. Evaluate merge readiness (CI + reviews)
161
+ 7. Execute merge operations (or dry-run)
162
+ """
163
+ config_file = find_config_file()
164
+ if config_file:
165
+ config, _ = load_config(config_file)
166
+ else:
167
+ config = Config()
168
+
169
+ cli_args = {
170
+ "base_branch": base_branch,
171
+ "dry_run": dry_run,
172
+ "exclude_labels": exclude_labels,
173
+ "include_labels": include_labels,
174
+ "merge_method": merge_method,
175
+ "bypass_review_count": bypass_review_count,
176
+ "owner": owner,
177
+ "prefix": prefix,
178
+ "repo_type": repo_type,
179
+ }
180
+ merged_config = merge_config_with_cli(config, cli_args)
181
+
182
+ # Display startup message first
183
+ print("🚀 Starting Merge-Pulls (Merge Pull Requests) on GitHub...\n")
184
+
185
+ # Load config if available (for display purposes)
186
+ if config_file:
187
+ print(f"📚 Loading configuration from: {config_file}")
188
+
189
+ # Display merged configuration
190
+ print("📚 Configuration (merged YAML and CLI):")
191
+ print(f" base-branch: {merged_config['base_branch']}")
192
+ print(f" bypass-review-count: {merged_config['bypass_review_count']}")
193
+ print(f" dry-run: {merged_config['dry_run']}")
194
+ print(f" exclude-labels: {merged_config['exclude_labels']}")
195
+ print(f" include-labels: {merged_config['include_labels']}")
196
+ print(f" merge-method: {merged_config['merge_method']}")
197
+ print(f" owner: {merged_config['owner']}")
198
+ print(f" prefix: {merged_config['prefix']}")
199
+ print(f" repo-type: {merged_config['repo_type']}")
200
+
201
+ print() # Empty line before actual operations
202
+
203
+ try:
204
+ # Create a single GitHub client instance for the rest of the process
205
+ gh, gh_token = get_auth()
206
+
207
+ # 1. Get Token User Data
208
+ user = gh.get_user()
209
+ login, orgs = get_token_user_info(gh, user)
210
+
211
+ # 2. Process Click Inputs (use merged config values)
212
+ owner = merged_config["owner"]
213
+ repo_type = merged_config["repo_type"]
214
+ merge_method = merged_config["merge_method"]
215
+ set_exclude_labels = merged_config["exclude_labels"]
216
+ set_include_labels = merged_config["include_labels"]
217
+ base_branch = merged_config["base_branch"]
218
+ prefix = merged_config["prefix"]
219
+ dry_run = merged_config["dry_run"]
220
+ bypass_review_count = merged_config["bypass_review_count"]
221
+
222
+ check_owner(owner=owner, orgs=orgs, login=login)
223
+ check_repo_type(repo_type=repo_type)
224
+ check_merge_method(merge_method=merge_method)
225
+
226
+ # 3. Get Repo Data
227
+ if owner:
228
+ repos_obj, repos = get_repos_from_owner(gh, user, owner, repo_type)
229
+ else:
230
+ repos_obj, repos = get_repos_from_all(gh, user, repo_type)
231
+
232
+ # 4. Get PR Data
233
+ list_open_prs = await get_open_prs(gh, repos_obj, base_branch, set_exclude_labels, set_include_labels, prefix)
234
+
235
+ # 5. Check Merge Readiness
236
+ list_mergeable_prs = await get_merge_readiness(gh, list_open_prs, base_branch, bypass_review_count, merge_method)
237
+
238
+ # 6. Merge PRs
239
+ list_merged_prs = await put_merge_pr(gh, list_mergeable_prs, merge_method, dry_run)
240
+
241
+ print_summary(len(list_open_prs), len(list_mergeable_prs), len(list_merged_prs))
242
+
243
+ except CustomError as err:
244
+ print(f"\n❌ Configuration Error: {err}")
245
+ sys.exit(1)
246
+ except (KeyError, PermissionError) as err:
247
+ print(f"\n❌ Authentication Error: {err}")
248
+ sys.exit(1)
249
+ except ValueError as err:
250
+ print(f"\n❌ Error: {err}")
251
+ sys.exit(1)
252
+ except Exception as err:
253
+ print(f"\n❌ Unexpected Error: {err}")
254
+ sys.exit(1)
255
+
256
+
257
+ if __name__ == "__main__": # pragma: no cover
258
+ asyncio.run(main())
@@ -0,0 +1,13 @@
1
+ """
2
+ Configuration management services.
3
+
4
+ Modules:
5
+ - config:
6
+ - Loading YAML configuration files
7
+ - Validating configuration against schema
8
+ - Merging CLI options with config file settings
9
+ """
10
+
11
+ from .config import Config, load_config, merge_config_with_cli
12
+
13
+ __all__ = ["Config", "load_config", "merge_config_with_cli"]
@@ -0,0 +1,213 @@
1
+ """
2
+ Configuration management and validation.
3
+
4
+ This module handles:
5
+ - Loading YAML configuration files from multiple locations
6
+ - Strict validation against a dataclass schema
7
+ - Merging CLI options with config file (CLI takes precedence over YAML)
8
+ - Label conversion (string to set) and boolean string parsing
9
+ - Error handling for invalid configurations
10
+ """
11
+
12
+ import os
13
+ from dataclasses import dataclass, fields
14
+ from typing import (
15
+ Any,
16
+ Dict,
17
+ List,
18
+ Optional,
19
+ )
20
+
21
+ import yaml
22
+
23
+
24
+ @dataclass
25
+ class Config:
26
+ """
27
+ Configuration model.
28
+
29
+ All fields are optional with defaults. CLI arguments take precedence over
30
+ this configuration when provided.
31
+
32
+ Attributes:
33
+ base_branch: Target base branch name (default: "main")
34
+ bypass_review_count: Bypass required review count (default: False)
35
+ dry_run: Preview mode without actual merges (default: True)
36
+ exclude_labels: List of labels to exclude to filter PRs (default: [])
37
+ include_labels: List of labels to include to filter PRs (default: [])
38
+ merge_method: Merge strategy: merge, rebase, or squash (default: "merge")
39
+ owner: Filter to specific user/org (default: "")
40
+ prefix: Filter PRs by title prefix (default: "")
41
+ repo_type: Repository type filter: all, private, or public (default: "all")
42
+ """
43
+
44
+ base_branch: str = "main"
45
+ dry_run: bool = True
46
+ exclude_labels: Optional[List[str]] = None
47
+ include_labels: Optional[List[str]] = None
48
+ merge_method: str = "merge"
49
+ bypass_review_count: bool = False
50
+ owner: str = ""
51
+ prefix: str = ""
52
+ repo_type: str = "all"
53
+
54
+ def __post_init__(self) -> None:
55
+ """Validate configuration after initialization."""
56
+ if self.exclude_labels is None:
57
+ self.exclude_labels = []
58
+
59
+ if self.include_labels is None:
60
+ self.include_labels = []
61
+
62
+ allowed_merge_methods = {"merge", "rebase", "squash"}
63
+ if self.merge_method not in allowed_merge_methods:
64
+ raise ValueError(f"merge_method must be one of {allowed_merge_methods}, got '{self.merge_method}'")
65
+
66
+ allowed_repo_types = {"all", "private", "public"}
67
+ if self.repo_type not in allowed_repo_types:
68
+ raise ValueError(f"repo_type must be one of {allowed_repo_types}, got '{self.repo_type}'")
69
+
70
+ # Reject extra fields
71
+ field_names = {f.name for f in fields(self)}
72
+ for key in self.__dict__.keys():
73
+ if key not in field_names:
74
+ raise ValueError(f"Invalid config field: '{key}'")
75
+
76
+
77
+ def load_config(config_path: Optional[str] = None) -> tuple[Config, Dict[str, Any]]:
78
+ """
79
+ Load configuration from YAML file.
80
+
81
+ For GitHub Actions users, config is expected at ./config/default.yaml.
82
+ For CLI users, this file is optional (dataclass defaults are used if not found).
83
+
84
+ Args:
85
+ config_path: Optional explicit path to config file (default: ./config/default.yaml).
86
+
87
+ Returns:
88
+ Tuple of (Config object, raw config dict)
89
+
90
+ Raises:
91
+ ValueError: If config validation fails.
92
+ yaml.YAMLError: If YAML parsing fails.
93
+ """
94
+ config_locations = []
95
+
96
+ if config_path:
97
+ config_locations.append(config_path)
98
+ else:
99
+ # Default location for GA users
100
+ config_locations.append("./config/default.yaml")
101
+
102
+ config_dict: Dict[str, Any] = {}
103
+
104
+ for location in config_locations:
105
+ if os.path.exists(location):
106
+ with open(location, "r") as f:
107
+ config_dict = yaml.safe_load(f) or {}
108
+ break
109
+
110
+ if not config_dict and not config_path:
111
+ config_dict = {}
112
+
113
+ try:
114
+ # Convert kebab-case keys to snake_case
115
+ converted_dict = _convert_kebab_to_snake(config_dict)
116
+ config = Config(**converted_dict)
117
+ return config, config_dict
118
+ except Exception as err:
119
+ source_file = config_locations[0] if config_path else "default configuration"
120
+ raise ValueError(f"Configuration error in {source_file}: {err}")
121
+
122
+
123
+ def _convert_kebab_to_snake(data: Dict[str, Any]) -> Dict[str, Any]:
124
+ """Convert kebab-case keys to snake_case for dataclass initialization."""
125
+ conversion_map = {
126
+ "base-branch": "base_branch",
127
+ "bypass-review-count": "bypass_review_count",
128
+ "dry-run": "dry_run",
129
+ "exclude-labels": "exclude_labels",
130
+ "include-labels": "include_labels",
131
+ "merge-method": "merge_method",
132
+ "repo-type": "repo_type",
133
+ }
134
+
135
+ result: Dict[str, Any] = {}
136
+ for key, value in data.items():
137
+ if key in conversion_map:
138
+ result[conversion_map[key]] = value
139
+ else:
140
+ # Keep unknown keys as-is (they'll be caught by __post_init__ validation if invalid)
141
+ result[key] = value
142
+
143
+ return result
144
+
145
+
146
+ def merge_config_with_cli(config: Config, cli_args: Dict[str, Any]) -> Dict[str, Any]:
147
+ """
148
+ Merge CLI arguments with config, giving precedence to CLI args.
149
+
150
+ Args:
151
+ config: Config object from YAML file (or defaults).
152
+ cli_args: Dictionary of CLI arguments.
153
+
154
+ Returns:
155
+ Dictionary with merged configuration (CLI overrides YAML).
156
+ """
157
+ merged = {
158
+ "base_branch": cli_args.get("base_branch") or config.base_branch,
159
+ "bypass_review_count": (
160
+ cli_args.get("bypass_review_count")
161
+ if cli_args.get("bypass_review_count") is not None
162
+ else config.bypass_review_count
163
+ ),
164
+ "dry_run": cli_args.get("dry_run") if cli_args.get("dry_run") is not None else config.dry_run,
165
+ "exclude_labels": cli_args.get("exclude_labels"),
166
+ "include_labels": cli_args.get("include_labels"),
167
+ "merge_method": cli_args.get("merge_method") or config.merge_method,
168
+ "owner": cli_args.get("owner") or config.owner,
169
+ "prefix": cli_args.get("prefix") or config.prefix,
170
+ "repo_type": cli_args.get("repo_type") or config.repo_type,
171
+ }
172
+
173
+ # Use config defaults for any None values (labels can be None, others use 'or' above)
174
+ if merged["exclude_labels"] is None:
175
+ merged["exclude_labels"] = config.exclude_labels
176
+ if merged["include_labels"] is None:
177
+ merged["include_labels"] = config.include_labels
178
+
179
+ # Handle exclude_labels: convert to set (CLI string or YAML list)
180
+ if isinstance(merged["exclude_labels"], str):
181
+ # Split on both commas and spaces for flexibility
182
+ # First replace commas with spaces, then split
183
+ exclude_label_str = merged["exclude_labels"].replace(",", " ")
184
+ merged["exclude_labels"] = (
185
+ {item.strip() for item in exclude_label_str.split() if item.strip()} if merged["exclude_labels"] else set()
186
+ )
187
+ elif isinstance(merged["exclude_labels"], list):
188
+ merged["exclude_labels"] = set(merged["exclude_labels"])
189
+ else:
190
+ # Handle None or other types (None falls back to config in merge, so this is defensive)
191
+ merged["exclude_labels"] = set()
192
+
193
+ # Handle include_labels: convert to set (CLI string or YAML list)
194
+ if isinstance(merged["include_labels"], str):
195
+ # Split on both commas and spaces for flexibility
196
+ # First replace commas with spaces, then split
197
+ include_label_str = merged["include_labels"].replace(",", " ")
198
+ merged["include_labels"] = (
199
+ {item.strip() for item in include_label_str.split() if item.strip()} if merged["include_labels"] else set()
200
+ )
201
+ elif isinstance(merged["include_labels"], list):
202
+ merged["include_labels"] = set(merged["include_labels"])
203
+ else:
204
+ # Handle None or other types (None falls back to config in merge, so this is defensive)
205
+ merged["include_labels"] = set()
206
+
207
+ # Convert boolean strings to actual booleans (for CLI args)
208
+ for bool_field in ["dry_run", "bypass_review_count"]:
209
+ if isinstance(merged[bool_field], str):
210
+ value = merged[bool_field].lower()
211
+ merged[bool_field] = value in ("true", "1", "yes")
212
+
213
+ return merged
File without changes