pullshark 2.4.6__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.
- pullshark/__init__.py +12 -0
- pullshark/__main__.py +6 -0
- pullshark/cli.py +161 -0
- pullshark/config.py +46 -0
- pullshark/core.py +291 -0
- pullshark/utils.py +132 -0
- pullshark-2.4.6.dist-info/METADATA +576 -0
- pullshark-2.4.6.dist-info/RECORD +12 -0
- pullshark-2.4.6.dist-info/WHEEL +5 -0
- pullshark-2.4.6.dist-info/entry_points.txt +2 -0
- pullshark-2.4.6.dist-info/licenses/LICENSE +21 -0
- pullshark-2.4.6.dist-info/top_level.txt +1 -0
pullshark/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PullShark — GitHub Pull Shark Achievement Automator.
|
|
3
|
+
|
|
4
|
+
Automate pull request creation and merging to unlock the Pull Shark achievement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "2.4.6"
|
|
8
|
+
__author__ = "Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ"
|
|
9
|
+
|
|
10
|
+
from pullshark.core import PullSharkBot
|
|
11
|
+
|
|
12
|
+
__all__ = ["PullSharkBot"]
|
pullshark/__main__.py
ADDED
pullshark/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for PullShark.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
pullshark run --token ghp_xxx --repo myrepo
|
|
6
|
+
pullshark run --token ghp_xxx --repo myrepo --dry-run --log run.log --output report.json
|
|
7
|
+
pullshark clean --token ghp_xxx --repo myrepo
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from pullshark.config import Config
|
|
17
|
+
from pullshark.core import PullSharkBot
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _setup_logging(log_file: str = "") -> None:
|
|
21
|
+
"""Configure logging for the pullshark package."""
|
|
22
|
+
logger = logging.getLogger("pullshark")
|
|
23
|
+
logger.setLevel(logging.DEBUG)
|
|
24
|
+
|
|
25
|
+
# Console handler — only warnings and above to stdout
|
|
26
|
+
console = logging.StreamHandler(sys.stdout)
|
|
27
|
+
console.setLevel(logging.WARNING)
|
|
28
|
+
console.setFormatter(logging.Formatter("%(message)s"))
|
|
29
|
+
logger.addHandler(console)
|
|
30
|
+
|
|
31
|
+
# File handler — everything if a log file is specified
|
|
32
|
+
if log_file:
|
|
33
|
+
fh = logging.FileHandler(log_file, mode="a", encoding="utf-8")
|
|
34
|
+
fh.setLevel(logging.DEBUG)
|
|
35
|
+
fh.setFormatter(
|
|
36
|
+
logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
|
37
|
+
)
|
|
38
|
+
logger.addHandler(fh)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
42
|
+
"""Add arguments shared by all subcommands."""
|
|
43
|
+
parser.add_argument("-t", "--token", required=True, help="GitHub Personal Access Token (with repo scope).")
|
|
44
|
+
parser.add_argument("-r", "--repo", required=True, help="Target repository name.")
|
|
45
|
+
parser.add_argument("-u", "--username", required=True, help="Your GitHub username.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
"""Build the CLI argument parser with subcommands."""
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="pullshark",
|
|
52
|
+
description="🦈 GitHub Pull Shark Achievement Automator — create and merge PRs automatically.",
|
|
53
|
+
)
|
|
54
|
+
sub = parser.add_subparsers(dest="command", help="Command to run")
|
|
55
|
+
|
|
56
|
+
# --- run ---
|
|
57
|
+
run_parser = sub.add_parser("run", help="Create and merge pull requests.")
|
|
58
|
+
_add_common_args(run_parser)
|
|
59
|
+
run_parser.add_argument("-n", "--prs", type=int, default=4, help="Number of PRs to create (default: 4).")
|
|
60
|
+
run_parser.add_argument("-b", "--branch", default="main", help="Base branch to target (default: main).")
|
|
61
|
+
run_parser.add_argument("-d", "--delay", type=int, default=10, help="Delay in seconds between PRs (default: 10).")
|
|
62
|
+
run_parser.add_argument("--max-retries", type=int, default=3, help="Maximum merge retry attempts (default: 3).")
|
|
63
|
+
run_parser.add_argument("--merge-method", choices=["merge", "squash", "rebase"], default="merge", help="Merge strategy (default: merge).")
|
|
64
|
+
run_parser.add_argument("--prefix", default="auto-pr", help="Branch name prefix (default: auto-pr).")
|
|
65
|
+
run_parser.add_argument("--dry-run", action="store_true", help="Preview what would happen without making changes.")
|
|
66
|
+
run_parser.add_argument("--check-rate", action="store_true", help="Check API rate limit before running.")
|
|
67
|
+
run_parser.add_argument("--log", default="", metavar="FILE", help="Save detailed logs to a file.")
|
|
68
|
+
run_parser.add_argument("--output", default="", metavar="FILE", help="Save run report as JSON.")
|
|
69
|
+
|
|
70
|
+
# --- clean ---
|
|
71
|
+
clean_parser = sub.add_parser("clean", help="Delete auto-created branches from the repo.")
|
|
72
|
+
_add_common_args(clean_parser)
|
|
73
|
+
clean_parser.add_argument("--prefix", default="auto-pr", help="Branch prefix to clean (default: auto-pr).")
|
|
74
|
+
clean_parser.add_argument("--dry-run", action="store_true", help="Show branches that would be deleted without deleting them.")
|
|
75
|
+
clean_parser.add_argument("--log", default="", metavar="FILE", help="Save detailed logs to a file.")
|
|
76
|
+
|
|
77
|
+
return parser
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run(args: argparse.Namespace) -> None:
|
|
81
|
+
"""Handle the 'run' command."""
|
|
82
|
+
_setup_logging(args.log)
|
|
83
|
+
logger = logging.getLogger("pullshark")
|
|
84
|
+
logger.info("Starting PullShark run command")
|
|
85
|
+
|
|
86
|
+
config = Config(
|
|
87
|
+
github_username=args.username,
|
|
88
|
+
github_token=args.token,
|
|
89
|
+
repo_name=args.repo,
|
|
90
|
+
num_prs=args.prs,
|
|
91
|
+
base_branch=args.branch,
|
|
92
|
+
delay_seconds=args.delay,
|
|
93
|
+
max_retries=args.max_retries,
|
|
94
|
+
dry_run=args.dry_run,
|
|
95
|
+
merge_method=args.merge_method,
|
|
96
|
+
branch_prefix=args.prefix,
|
|
97
|
+
log_file=args.log,
|
|
98
|
+
output_file=args.output,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
errors = config.validate()
|
|
102
|
+
if errors:
|
|
103
|
+
for err in errors:
|
|
104
|
+
print(f"❌ {err}", file=sys.stderr)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
bot = PullSharkBot(config)
|
|
108
|
+
|
|
109
|
+
if args.check_rate:
|
|
110
|
+
info = bot.check_rate_limit()
|
|
111
|
+
print(f"📊 API Rate Limit: {info['remaining']}/{info['limit']} remaining (resets {info['reset']})")
|
|
112
|
+
if not info["enough_for_run"]:
|
|
113
|
+
print(f"⚠️ May not have enough calls for {config.num_prs} PRs. Consider waiting.\n")
|
|
114
|
+
else:
|
|
115
|
+
print("✅ Enough API calls available.\n")
|
|
116
|
+
|
|
117
|
+
successful = bot.run()
|
|
118
|
+
logger.info("Run complete: %d/%d merged", successful, config.num_prs)
|
|
119
|
+
sys.exit(0 if successful >= 2 else 1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _clean(args: argparse.Namespace) -> None:
|
|
123
|
+
"""Handle the 'clean' command."""
|
|
124
|
+
_setup_logging(args.log)
|
|
125
|
+
|
|
126
|
+
config = Config(
|
|
127
|
+
github_username=args.username,
|
|
128
|
+
github_token=args.token,
|
|
129
|
+
repo_name=args.repo,
|
|
130
|
+
branch_prefix=args.prefix,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
bot = PullSharkBot(config)
|
|
134
|
+
bot.clean(dry_run=args.dry_run)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str] | None = None) -> None:
|
|
138
|
+
"""CLI entry point."""
|
|
139
|
+
parser = build_parser()
|
|
140
|
+
|
|
141
|
+
if argv is None:
|
|
142
|
+
argv = sys.argv[1:]
|
|
143
|
+
|
|
144
|
+
# Default to 'run' if no subcommand given
|
|
145
|
+
if len(argv) > 0 and argv[0] not in ("run", "clean", "--help", "-h"):
|
|
146
|
+
argv = ["run"] + argv
|
|
147
|
+
|
|
148
|
+
args = parser.parse_args(argv)
|
|
149
|
+
|
|
150
|
+
if args.command is None:
|
|
151
|
+
parser.print_help()
|
|
152
|
+
sys.exit(0)
|
|
153
|
+
|
|
154
|
+
if args.command == "run":
|
|
155
|
+
_run(args)
|
|
156
|
+
elif args.command == "clean":
|
|
157
|
+
_clean(args)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
main()
|
pullshark/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration defaults and validation for PullShark.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Config:
|
|
12
|
+
"""PullShark configuration with sensible defaults."""
|
|
13
|
+
|
|
14
|
+
github_username: str = ""
|
|
15
|
+
github_token: str = ""
|
|
16
|
+
repo_name: str = ""
|
|
17
|
+
num_prs: int = 4
|
|
18
|
+
base_branch: str = "main"
|
|
19
|
+
delay_seconds: int = 10
|
|
20
|
+
max_retries: int = 3
|
|
21
|
+
dry_run: bool = False
|
|
22
|
+
merge_method: str = "merge"
|
|
23
|
+
branch_prefix: str = "auto-pr"
|
|
24
|
+
log_file: str = ""
|
|
25
|
+
output_file: str = ""
|
|
26
|
+
|
|
27
|
+
def validate(self) -> list[str]:
|
|
28
|
+
"""Return a list of validation errors (empty = valid)."""
|
|
29
|
+
errors: list[str] = []
|
|
30
|
+
if not self.github_username:
|
|
31
|
+
errors.append("GITHUB_USERNAME is required.")
|
|
32
|
+
if not self.github_token:
|
|
33
|
+
errors.append("GITHUB_TOKEN is required.")
|
|
34
|
+
if not self.repo_name:
|
|
35
|
+
errors.append("REPO_NAME is required.")
|
|
36
|
+
if self.num_prs < 1:
|
|
37
|
+
errors.append("NUM_PRS must be at least 1.")
|
|
38
|
+
if self.delay_seconds < 0:
|
|
39
|
+
errors.append("DELAY_SECONDS cannot be negative.")
|
|
40
|
+
if self.max_retries < 1:
|
|
41
|
+
errors.append("MAX_RETRIES must be at least 1.")
|
|
42
|
+
if self.merge_method not in ("merge", "squash", "rebase"):
|
|
43
|
+
errors.append("MERGE_METHOD must be one of: merge, squash, rebase.")
|
|
44
|
+
if not self.branch_prefix:
|
|
45
|
+
errors.append("BRANCH_PREFIX cannot be empty.")
|
|
46
|
+
return errors
|
pullshark/core.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core automation logic for PullShark.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from github import Github, GithubException
|
|
11
|
+
from github.Repository import Repository
|
|
12
|
+
|
|
13
|
+
from pullshark import __version__
|
|
14
|
+
from pullshark.config import Config
|
|
15
|
+
from pullshark.utils import (
|
|
16
|
+
build_run_report,
|
|
17
|
+
generate_random_string,
|
|
18
|
+
merge_with_retry,
|
|
19
|
+
save_report,
|
|
20
|
+
wait_for_mergeability,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("pullshark")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PullSharkBot:
|
|
27
|
+
"""Automates PR creation and merging for the Pull Shark achievement."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: Config) -> None:
|
|
30
|
+
self.config = config
|
|
31
|
+
self._github: Github | None = None
|
|
32
|
+
self._repo: Repository | None = None
|
|
33
|
+
|
|
34
|
+
# -- Properties -----------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def github(self) -> Github:
|
|
38
|
+
if self._github is None:
|
|
39
|
+
try:
|
|
40
|
+
import github.Auth
|
|
41
|
+
self._github = Github(auth=github.Auth.Token(self.config.github_token))
|
|
42
|
+
except (ImportError, AttributeError):
|
|
43
|
+
self._github = Github(self.config.github_token)
|
|
44
|
+
return self._github
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def repo(self) -> Repository:
|
|
48
|
+
if self._repo is None:
|
|
49
|
+
self._repo = self.github.get_user().get_repo(self.config.repo_name)
|
|
50
|
+
return self._repo
|
|
51
|
+
|
|
52
|
+
# -- Public API -----------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def run(self) -> int:
|
|
55
|
+
"""Execute the full automation loop.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Number of successfully merged pull requests.
|
|
59
|
+
"""
|
|
60
|
+
cfg = self.config
|
|
61
|
+
successful = 0
|
|
62
|
+
results: list[dict] = []
|
|
63
|
+
|
|
64
|
+
self._print_header()
|
|
65
|
+
|
|
66
|
+
if cfg.dry_run:
|
|
67
|
+
print("🔍 DRY RUN — no branches, commits, or PRs will be created.\n")
|
|
68
|
+
logger.info("Dry run mode enabled")
|
|
69
|
+
|
|
70
|
+
for i in range(cfg.num_prs):
|
|
71
|
+
print(f"\n--- 📦 PR #{i + 1} of {cfg.num_prs} ---")
|
|
72
|
+
pr_result: dict = {"index": i + 1, "merged": False}
|
|
73
|
+
|
|
74
|
+
if cfg.dry_run:
|
|
75
|
+
self._dry_run_preview(i + 1)
|
|
76
|
+
pr_result["merged"] = True
|
|
77
|
+
pr_result["dry_run"] = True
|
|
78
|
+
results.append(pr_result)
|
|
79
|
+
successful += 1
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
branch_name = self._create_branch()
|
|
83
|
+
if not branch_name:
|
|
84
|
+
pr_result["error"] = "Failed to create branch"
|
|
85
|
+
results.append(pr_result)
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
pr_result["branch"] = branch_name
|
|
89
|
+
self._make_commit(branch_name)
|
|
90
|
+
|
|
91
|
+
pr = self._open_pull_request(i + 1, branch_name)
|
|
92
|
+
if pr is None:
|
|
93
|
+
pr_result["error"] = "Failed to create PR"
|
|
94
|
+
results.append(pr_result)
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
pr_result["pr_number"] = pr.number
|
|
98
|
+
pr_result["pr_url"] = pr.html_url
|
|
99
|
+
|
|
100
|
+
print(" ⏳ Waiting for GitHub to calculate mergeability...")
|
|
101
|
+
if not wait_for_mergeability(pr):
|
|
102
|
+
print(" ❌ PR not mergeable after waiting. Stopping.")
|
|
103
|
+
pr_result["error"] = "Not mergeable"
|
|
104
|
+
results.append(pr_result)
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
if merge_with_retry(pr, cfg.max_retries, cfg.delay_seconds, cfg.merge_method):
|
|
108
|
+
print(f" 🎉 Merged PR #{pr.number}")
|
|
109
|
+
pr_result["merged"] = True
|
|
110
|
+
successful += 1
|
|
111
|
+
else:
|
|
112
|
+
print(" ❌ All merge attempts exhausted. Stopping.")
|
|
113
|
+
pr_result["error"] = "Merge failed after retries"
|
|
114
|
+
results.append(pr_result)
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
results.append(pr_result)
|
|
118
|
+
|
|
119
|
+
if i < cfg.num_prs - 1:
|
|
120
|
+
print(f" ⏸️ Pausing {cfg.delay_seconds}s for GitHub to process...")
|
|
121
|
+
time.sleep(cfg.delay_seconds)
|
|
122
|
+
|
|
123
|
+
# Save JSON report if requested
|
|
124
|
+
if cfg.output_file:
|
|
125
|
+
report = build_run_report(results, cfg)
|
|
126
|
+
save_report(report, cfg.output_file)
|
|
127
|
+
print(f"\n📄 Report saved to {cfg.output_file}")
|
|
128
|
+
|
|
129
|
+
self._print_summary(successful)
|
|
130
|
+
return successful
|
|
131
|
+
|
|
132
|
+
def clean(self, dry_run: bool = False) -> int:
|
|
133
|
+
"""Delete all auto-created branches from the repo.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Number of branches deleted (or that would be deleted in dry-run).
|
|
137
|
+
"""
|
|
138
|
+
prefix = self.config.branch_prefix
|
|
139
|
+
deleted = 0
|
|
140
|
+
|
|
141
|
+
print(f"\n🧹 Scanning for '{prefix}*' branches in {self.config.repo_name}...\n")
|
|
142
|
+
logger.info("Scanning for '%s*' branches", prefix)
|
|
143
|
+
|
|
144
|
+
for branch in self.repo.get_branches():
|
|
145
|
+
if branch.name.startswith(prefix):
|
|
146
|
+
if dry_run:
|
|
147
|
+
print(f" 🔍 Would delete: {branch.name}")
|
|
148
|
+
logger.info("Would delete: %s", branch.name)
|
|
149
|
+
else:
|
|
150
|
+
try:
|
|
151
|
+
ref = self.repo.get_git_ref(f"heads/{branch.name}")
|
|
152
|
+
ref.delete()
|
|
153
|
+
print(f" 🗑️ Deleted: {branch.name}")
|
|
154
|
+
logger.info("Deleted: %s", branch.name)
|
|
155
|
+
except GithubException as e:
|
|
156
|
+
print(f" ❌ Failed to delete {branch.name}: {e}")
|
|
157
|
+
logger.error("Failed to delete %s: %s", branch.name, e)
|
|
158
|
+
continue
|
|
159
|
+
deleted += 1
|
|
160
|
+
|
|
161
|
+
if deleted == 0:
|
|
162
|
+
print(" ✨ No matching branches found. Repo is clean!")
|
|
163
|
+
else:
|
|
164
|
+
action = "would be deleted" if dry_run else "deleted"
|
|
165
|
+
print(f"\n🏁 {deleted} branch(es) {action}.")
|
|
166
|
+
|
|
167
|
+
return deleted
|
|
168
|
+
|
|
169
|
+
def check_rate_limit(self) -> dict:
|
|
170
|
+
"""Check GitHub API rate limit status.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Dict with rate limit info.
|
|
174
|
+
"""
|
|
175
|
+
rate = self.github.get_rate_limit()
|
|
176
|
+
core = getattr(rate, "core", None) or rate.rate
|
|
177
|
+
return {
|
|
178
|
+
"remaining": core.remaining,
|
|
179
|
+
"limit": core.limit,
|
|
180
|
+
"reset": core.reset.strftime("%H:%M:%S"),
|
|
181
|
+
"enough_for_run": core.remaining >= self.config.num_prs * 4,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# -- Internals ------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _print_header(self) -> None:
|
|
187
|
+
cfg = self.config
|
|
188
|
+
mode = " [DRY RUN]" if cfg.dry_run else ""
|
|
189
|
+
print(f"\nConfiguration: user='{cfg.github_username}' repo='{cfg.repo_name}'{mode}")
|
|
190
|
+
print(f"Base branch: {cfg.base_branch}")
|
|
191
|
+
print(f"Will create {cfg.num_prs} PR(s) with {cfg.delay_seconds}s delay.")
|
|
192
|
+
print(f"Merge method: {cfg.merge_method}")
|
|
193
|
+
print(f"Branch prefix: {cfg.branch_prefix}\n")
|
|
194
|
+
|
|
195
|
+
def _dry_run_preview(self, index: int) -> None:
|
|
196
|
+
"""Preview what a PR would look like without creating anything."""
|
|
197
|
+
prefix = self.config.branch_prefix
|
|
198
|
+
branch_name = f"{prefix}-{generate_random_string(6)}-{int(time.time())}"
|
|
199
|
+
print(f" 📋 Would create branch: {branch_name}")
|
|
200
|
+
print(" 📝 Would commit to: README.md")
|
|
201
|
+
print(f" 🔗 Would open PR #{index}: Auto-PR {generate_random_string(4)} #{index}")
|
|
202
|
+
print(f" 🎉 Would merge via: {self.config.merge_method}")
|
|
203
|
+
|
|
204
|
+
def _create_branch(self) -> str | None:
|
|
205
|
+
"""Create a new branch from the latest base commit."""
|
|
206
|
+
cfg = self.config
|
|
207
|
+
base = self.repo.get_branch(cfg.base_branch)
|
|
208
|
+
sha = base.commit.sha
|
|
209
|
+
print(f" Latest {cfg.base_branch} commit: {sha[:7]}")
|
|
210
|
+
|
|
211
|
+
branch_name = f"{cfg.branch_prefix}-{generate_random_string(6)}-{int(time.time())}"
|
|
212
|
+
try:
|
|
213
|
+
self.repo.create_git_ref(f"refs/heads/{branch_name}", sha)
|
|
214
|
+
print(f" ✅ Created branch: {branch_name}")
|
|
215
|
+
logger.info("Created branch: %s", branch_name)
|
|
216
|
+
return branch_name
|
|
217
|
+
except GithubException as e:
|
|
218
|
+
print(f" ❌ Failed to create branch: {e}")
|
|
219
|
+
logger.error("Failed to create branch: %s", e)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
# Watermark & credits appended to every commit and PR
|
|
223
|
+
_WATERMARK = (
|
|
224
|
+
"\n\n---\n"
|
|
225
|
+
"<div align=\"center\">\n\n"
|
|
226
|
+
"🦈 **Automated by [PullShark](https://github.com/Shineii86/PullShark)**\n\n"
|
|
227
|
+
"[](https://github.com/Shineii86) "
|
|
228
|
+
"[](https://github.com/Shineii86/PullShark) "
|
|
229
|
+
"[](https://github.com/Shineii86/PullShark/stargazers)\n\n"
|
|
230
|
+
"</div>"
|
|
231
|
+
).format(version=__version__)
|
|
232
|
+
|
|
233
|
+
def _make_commit(self, branch_name: str) -> None:
|
|
234
|
+
"""Append a small change to README.md on the given branch."""
|
|
235
|
+
try:
|
|
236
|
+
contents = self.repo.get_contents("README.md", ref=branch_name)
|
|
237
|
+
new_content = (
|
|
238
|
+
f"{contents.decoded_content.decode()}\n"
|
|
239
|
+
f"- 🤖 Auto-update by PullShark: {generate_random_string()}"
|
|
240
|
+
f"{self._WATERMARK}"
|
|
241
|
+
)
|
|
242
|
+
self.repo.update_file(
|
|
243
|
+
contents.path,
|
|
244
|
+
f"Automated update in {branch_name}",
|
|
245
|
+
new_content,
|
|
246
|
+
contents.sha,
|
|
247
|
+
branch=branch_name,
|
|
248
|
+
)
|
|
249
|
+
print(" 📝 Updated README.md")
|
|
250
|
+
logger.info("Updated README.md on %s", branch_name)
|
|
251
|
+
except GithubException as e:
|
|
252
|
+
if e.status != 404:
|
|
253
|
+
raise
|
|
254
|
+
self.repo.create_file(
|
|
255
|
+
"README.md",
|
|
256
|
+
f"Create README in {branch_name}",
|
|
257
|
+
f"# {self.config.repo_name}\n\nAuto-generated: {generate_random_string()}{self._WATERMARK}",
|
|
258
|
+
branch=branch_name,
|
|
259
|
+
)
|
|
260
|
+
print(" 📄 Created README.md")
|
|
261
|
+
logger.info("Created README.md on %s", branch_name)
|
|
262
|
+
|
|
263
|
+
def _open_pull_request(self, index: int, branch_name: str):
|
|
264
|
+
"""Create a pull request."""
|
|
265
|
+
try:
|
|
266
|
+
pr = self.repo.create_pull(
|
|
267
|
+
title=f"Auto-PR {generate_random_string(4)} #{index}",
|
|
268
|
+
body=(
|
|
269
|
+
"🤖 Automated pull request for the Pull Shark achievement."
|
|
270
|
+
f"{self._WATERMARK}"
|
|
271
|
+
),
|
|
272
|
+
head=branch_name,
|
|
273
|
+
base=self.config.base_branch,
|
|
274
|
+
)
|
|
275
|
+
print(f" 🔗 Created PR: {pr.html_url}")
|
|
276
|
+
logger.info("Created PR #%d: %s", pr.number, pr.html_url)
|
|
277
|
+
return pr
|
|
278
|
+
except GithubException as e:
|
|
279
|
+
print(f" ❌ Failed to create PR: {e}")
|
|
280
|
+
logger.error("Failed to create PR: %s", e)
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def _print_summary(self, successful: int) -> None:
|
|
284
|
+
total = self.config.num_prs
|
|
285
|
+
mode = " (dry run)" if self.config.dry_run else ""
|
|
286
|
+
print(f"\n🏁 Finished. {successful} out of {total} pull requests {'would be' if self.config.dry_run else ''} merged{mode}.")
|
|
287
|
+
if not self.config.dry_run:
|
|
288
|
+
if successful >= 2:
|
|
289
|
+
print("🦈 Congratulations! You've met the requirements for Pull Shark!")
|
|
290
|
+
else:
|
|
291
|
+
print("⚠️ You need at least 2 merged PRs for the achievement.")
|
pullshark/utils.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility helpers for PullShark.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import random
|
|
10
|
+
import string
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from github import GithubException
|
|
15
|
+
from github.PullRequest import PullRequest
|
|
16
|
+
|
|
17
|
+
from pullshark import __version__
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("pullshark")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate_random_string(length: int = 8) -> str:
|
|
23
|
+
"""Generate a random lowercase string for branch names and commit messages."""
|
|
24
|
+
return "".join(random.choices(string.ascii_lowercase, k=length))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def wait_for_mergeability(pr: PullRequest, max_wait: int = 30) -> bool:
|
|
28
|
+
"""Poll GitHub until the PR is mergeable or timeout is reached.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
pr: The PullRequest object to poll.
|
|
32
|
+
max_wait: Maximum seconds to wait.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
True if the PR is mergeable, False on timeout.
|
|
36
|
+
"""
|
|
37
|
+
waited = 0
|
|
38
|
+
while waited < max_wait:
|
|
39
|
+
pr.update()
|
|
40
|
+
if pr.mergeable is not False: # True or None (still calculating)
|
|
41
|
+
return True
|
|
42
|
+
time.sleep(3)
|
|
43
|
+
waited += 3
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def merge_with_retry(
|
|
48
|
+
pr: PullRequest,
|
|
49
|
+
max_retries: int = 3,
|
|
50
|
+
delay_seconds: int = 10,
|
|
51
|
+
merge_method: str = "merge",
|
|
52
|
+
) -> bool:
|
|
53
|
+
"""Attempt to merge a PR with retry logic.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
pr: The PullRequest to merge.
|
|
57
|
+
max_retries: Number of attempts before giving up.
|
|
58
|
+
delay_seconds: Wait time between retries.
|
|
59
|
+
merge_method: One of 'merge', 'squash', 'rebase'.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if merge succeeded, False otherwise.
|
|
63
|
+
"""
|
|
64
|
+
for attempt in range(1, max_retries + 1):
|
|
65
|
+
try:
|
|
66
|
+
pr.merge(merge_method=merge_method)
|
|
67
|
+
return True
|
|
68
|
+
except GithubException as e:
|
|
69
|
+
logger.warning("Merge attempt %d/%d failed: %s", attempt, max_retries, e)
|
|
70
|
+
if attempt < max_retries:
|
|
71
|
+
logger.info("Waiting %ds before retry...", delay_seconds)
|
|
72
|
+
time.sleep(delay_seconds)
|
|
73
|
+
pr.update()
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_run_report(results: list[dict], config) -> dict:
|
|
78
|
+
"""Build a structured JSON report of the run.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
results: List of per-PR result dicts.
|
|
82
|
+
config: The Config object used for the run.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Report dict ready for JSON serialization.
|
|
86
|
+
"""
|
|
87
|
+
successful = sum(1 for r in results if r.get("merged"))
|
|
88
|
+
return {
|
|
89
|
+
"version": __version__,
|
|
90
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
91
|
+
"config": {
|
|
92
|
+
"username": config.github_username,
|
|
93
|
+
"repo": config.repo_name,
|
|
94
|
+
"base_branch": config.base_branch,
|
|
95
|
+
"num_prs": config.num_prs,
|
|
96
|
+
"merge_method": config.merge_method,
|
|
97
|
+
"branch_prefix": config.branch_prefix,
|
|
98
|
+
"delay_seconds": config.delay_seconds,
|
|
99
|
+
"max_retries": config.max_retries,
|
|
100
|
+
},
|
|
101
|
+
"summary": {
|
|
102
|
+
"total": config.num_prs,
|
|
103
|
+
"successful": successful,
|
|
104
|
+
"failed": config.num_prs - successful,
|
|
105
|
+
"pull_shark_tier": _get_tier(successful),
|
|
106
|
+
},
|
|
107
|
+
"pull_requests": results,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_tier(count: int) -> str:
|
|
112
|
+
if count >= 1024:
|
|
113
|
+
return "Gold"
|
|
114
|
+
if count >= 128:
|
|
115
|
+
return "Silver"
|
|
116
|
+
if count >= 16:
|
|
117
|
+
return "Bronze"
|
|
118
|
+
if count >= 2:
|
|
119
|
+
return "Default"
|
|
120
|
+
return "None"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def save_report(report: dict, filepath: str) -> None:
|
|
124
|
+
"""Save a run report to a JSON file.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
report: The report dict from build_run_report.
|
|
128
|
+
filepath: Path to write the JSON file.
|
|
129
|
+
"""
|
|
130
|
+
with open(filepath, "w") as f:
|
|
131
|
+
json.dump(report, f, indent=2)
|
|
132
|
+
logger.info("Report saved to %s", filepath)
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pullshark
|
|
3
|
+
Version: 2.4.6
|
|
4
|
+
Summary: Automate pull request creation and merging to unlock GitHub's Pull Shark achievement.
|
|
5
|
+
Author-email: Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ <ikx7a@hotmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Shineii86/PullShark
|
|
8
|
+
Project-URL: Repository, https://github.com/Shineii86/PullShark
|
|
9
|
+
Project-URL: Issues, https://github.com/Shineii86/PullShark/issues
|
|
10
|
+
Keywords: github,pull-request,automation,achievement,pull-shark
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: PyGithub>=2.1.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
<div align="center">
|
|
31
|
+
|
|
32
|
+
[](https://github.com/Shineii86/PullShark)
|
|
33
|
+
|
|
34
|
+
[](https://colab.research.google.com/github/Shineii86/PullShark/blob/main/notebooks/PullShark.ipynb)
|
|
35
|
+
[](https://github.com/Shineii86/PullShark/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/pullshark/)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
[](https://github.com/Shineii86/PullShark/stargazers)
|
|
41
|
+
[](https://github.com/Shineii86/PullShark/fork)
|
|
42
|
+
|
|
43
|
+
A **fully automated** Python tool that creates and merges multiple pull requests in your GitHub repository — helping you earn the coveted **Pull Shark** achievement. Runs in **Google Colab** or directly from your **terminal**.
|
|
44
|
+
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
> [!WARNING]
|
|
50
|
+
> **This script creates real pull requests on your GitHub account.**
|
|
51
|
+
> - Never share your **Personal Access Token** — treat it like a password.
|
|
52
|
+
> - Use a **dedicated repository** to avoid cluttering important projects.
|
|
53
|
+
> - GitHub may rate‑limit excessive API calls; the built‑in delay helps prevent this.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 📖 Table of Contents
|
|
58
|
+
|
|
59
|
+
- [What is Pull Shark?](#-what-is-pull-shark)
|
|
60
|
+
- [Features](#-features)
|
|
61
|
+
- [Prerequisites](#-prerequisites)
|
|
62
|
+
- [Project Structure](#-project-structure)
|
|
63
|
+
- [Installation](#-installation)
|
|
64
|
+
- [Usage](#-usage)
|
|
65
|
+
- [Google Colab](#1️⃣-google-colab)
|
|
66
|
+
- [Command Line (CLI)](#2️⃣-command-line-cli)
|
|
67
|
+
- [As a Python Package](#3️⃣-as-a-python-package)
|
|
68
|
+
- [Configuration Options](#-configuration-options)
|
|
69
|
+
- [How It Works](#-how-it-works-technical-overview)
|
|
70
|
+
- [Merge Methods](#-merge-methods)
|
|
71
|
+
- [Testing & Contributing](#-testing--contributing)
|
|
72
|
+
- [Troubleshooting](#-troubleshooting)
|
|
73
|
+
- [Changelog](#-changelog)
|
|
74
|
+
- [License & Disclaimer](#-license--disclaimer)
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 🎯 What is Pull Shark?
|
|
79
|
+
|
|
80
|
+
**Pull Shark** is a GitHub achievement awarded when you have **at least 2 pull requests merged** into any repository. It's one of the most popular achievements and a fun way to show off your open-source contributions.
|
|
81
|
+
|
|
82
|
+
This script automates the creation and merging of pull requests, so you can unlock the achievement in **under 5 minutes** — from your browser or terminal.
|
|
83
|
+
|
|
84
|
+
| Base | Bronze | Silver | Gold |
|
|
85
|
+
| :--: | :----: | :----: | :--: |
|
|
86
|
+
| [](https://github.com/Shineii86/PullShark) | [](https://github.com/Shineii86/PullShark) | [](https://github.com/Shineii86/PullShark) | [](https://github.com/Shineii86/PullShark) |
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
> To earn the "**Pull Shark**" achievement on GitHub, you need to have your pull requests (PRs) merged. The badge has four tiers, each requiring a specific number of merged PRs.
|
|
90
|
+
|
|
91
|
+
### 🦈 Pull Shark Achievement Tiers
|
|
92
|
+
|
|
93
|
+
| Tier | Badge Name | Required Merged Pull Requests |
|
|
94
|
+
| :--- | :--- | :--- |
|
|
95
|
+
| 1 | **Default / x1** | **2** merged PRs |
|
|
96
|
+
| 2 | **Bronze / x2** | **16** merged PRs |
|
|
97
|
+
| 3 | **Silver / x3** | **128** merged PRs |
|
|
98
|
+
| 4 | **Gold / x4** | **1024** merged PRs |
|
|
99
|
+
|
|
100
|
+
> [!IMPORTANT]
|
|
101
|
+
> Only merged PRs count toward this achievement. PRs that are closed without being merged do not contribute to your progress.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## ✨ Features
|
|
106
|
+
|
|
107
|
+
| Feature | Description |
|
|
108
|
+
|:--------|:------------|
|
|
109
|
+
| ☁️ **Google Colab** | One-click notebook with 5-step guided flow — no install needed |
|
|
110
|
+
| 🎨 **Dark Mode** | Colab notebook auto-adapts to light and dark themes |
|
|
111
|
+
| 🖥️ **CLI** | Full terminal interface with `run` and `clean` subcommands |
|
|
112
|
+
| 🔍 **Dry Run** | Preview branches, commits, and PRs without making any changes |
|
|
113
|
+
| 🧹 **Branch Cleanup** | Bulk-delete auto-created branches after a run |
|
|
114
|
+
| 📊 **Rate Limit Check** | View your API quota before starting — prevents mid-run failures |
|
|
115
|
+
| 🔀 **Merge Strategies** | Choose between `merge`, `squash`, or `rebase` methods |
|
|
116
|
+
| 🔄 **Retry Logic** | Automatically retries failed merges with configurable attempts |
|
|
117
|
+
| 📝 **Logging** | `--log file.log` saves timestamped debug output for auditing |
|
|
118
|
+
| 📄 **JSON Reports** | `--output report.json` saves structured run results |
|
|
119
|
+
| 🏷️ **Custom Prefix** | `--prefix mybot` to customize branch names |
|
|
120
|
+
| 📦 **Python Package** | Import into your own scripts for custom workflows |
|
|
121
|
+
| 🐍 **`python -m`** | Run as `python -m pullshark` without installing |
|
|
122
|
+
| ✅ **Validation** | Catches config errors before making any API calls |
|
|
123
|
+
| 🧪 **Tested** | 35+ pytest tests with CI on Python 3.9–3.12 |
|
|
124
|
+
| 📦 **PyPI** | `pip install pullshark` — install from PyPI |
|
|
125
|
+
| 🔧 **Pre-Commit** | Ruff lint/format + pytest hooks for contributors |
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 🧰 Prerequisites
|
|
130
|
+
|
|
131
|
+
Before you begin, make sure you have:
|
|
132
|
+
|
|
133
|
+
1. **A GitHub account** — obviously 😄
|
|
134
|
+
2. **A repository** where you have **write access** (you can create a new one just for this).
|
|
135
|
+
3. **A GitHub Personal Access Token** with `repo` scope.
|
|
136
|
+
|
|
137
|
+
### 🔐 How to Get a Personal Access Token
|
|
138
|
+
|
|
139
|
+
<details>
|
|
140
|
+
<summary><b>Classic Token (Recommended for beginners)</b></summary>
|
|
141
|
+
|
|
142
|
+
1. Go to **Settings** → **Developer settings** → **Personal access tokens** → **Tokens (classic)**.
|
|
143
|
+
2. Click **Generate new token (classic)**.
|
|
144
|
+
3. Give it a name (e.g., `Pull Shark Bot`).
|
|
145
|
+
4. Under **Select scopes**, check **`repo`**.
|
|
146
|
+
5. Click **Generate token** and **copy it immediately**.
|
|
147
|
+
|
|
148
|
+
</details>
|
|
149
|
+
|
|
150
|
+
<details>
|
|
151
|
+
<summary><b>Fine-Grained Token (More secure, recommended)</b></summary>
|
|
152
|
+
|
|
153
|
+
1. Go to **Settings** → **Developer settings** → **Personal access tokens** → **Fine-grained tokens**.
|
|
154
|
+
2. Click **Generate new token**.
|
|
155
|
+
3. Set **Resource owner** to your username.
|
|
156
|
+
4. Under **Repository access**, select **Only select repositories** → pick your target repo.
|
|
157
|
+
5. Under **Permissions** → **Repository permissions**, set:
|
|
158
|
+
- **Contents**: `Read and write`
|
|
159
|
+
- **Metadata**: `Read-only`
|
|
160
|
+
- **Pull requests**: `Read and write`
|
|
161
|
+
6. Click **Generate token** and copy it.
|
|
162
|
+
|
|
163
|
+
</details>
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 📁 Project Structure
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
PullShark/
|
|
171
|
+
├── pullshark/ # Python package
|
|
172
|
+
│ ├── __init__.py # Package init & version
|
|
173
|
+
│ ├── __main__.py # python -m pullshark support
|
|
174
|
+
│ ├── config.py # Configuration dataclass with validation
|
|
175
|
+
│ ├── core.py # PullSharkBot — main automation logic
|
|
176
|
+
│ ├── utils.py # Helpers (random strings, mergeability, reports)
|
|
177
|
+
│ └── cli.py # Command-line interface (run & clean subcommands)
|
|
178
|
+
├── tests/
|
|
179
|
+
│ └── test_pullshark.py # pytest test suite (35+ tests)
|
|
180
|
+
├── notebooks/
|
|
181
|
+
│ └── PullShark.ipynb # Google Colab notebook (5-step guided flow)
|
|
182
|
+
├── .github/
|
|
183
|
+
│ ├── workflows/
|
|
184
|
+
│ │ ├── ci.yml # CI on push/PR (Python 3.9–3.12)
|
|
185
|
+
│ │ └── publish.yml # PyPI publish on tag
|
|
186
|
+
│ └── dependabot.yml # Auto dependency updates
|
|
187
|
+
├── images/ # Achievement badge images
|
|
188
|
+
├── pyproject.toml # Python packaging + pytest + ruff config
|
|
189
|
+
├── requirements.txt # Dependencies
|
|
190
|
+
├── .pre-commit-config.yaml # Pre-commit hooks (ruff, pytest, linting)
|
|
191
|
+
├── CONTRIBUTING.md # Contribution guidelines
|
|
192
|
+
├── CHANGELOG.md # Version history
|
|
193
|
+
├── LICENSE # MIT License
|
|
194
|
+
└── README.md # This file
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## 📥 Installation
|
|
200
|
+
|
|
201
|
+
### From PyPI (Easiest)
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
pip install pullshark
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### From Source (Recommended for Contributors)
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
git clone https://github.com/Shineii86/PullShark.git
|
|
211
|
+
cd PullShark
|
|
212
|
+
pip install -e .
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
This installs the `pullshark` CLI command and makes the package importable.
|
|
216
|
+
|
|
217
|
+
### With Dev Dependencies (For Contributors)
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pip install -e ".[dev]"
|
|
221
|
+
pip install pre-commit && pre-commit install
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Installs pytest, pytest-cov, ruff, and sets up pre-commit hooks.
|
|
225
|
+
|
|
226
|
+
### Dependencies Only
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install PyGithub>=2.1.0
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 🚀 Usage
|
|
235
|
+
|
|
236
|
+
### 1️⃣ Google Colab
|
|
237
|
+
|
|
238
|
+
<a href="https://colab.research.google.com/github/Shineii86/PullShark/blob/main/notebooks/PullShark.ipynb">
|
|
239
|
+
<img src="https://user-images.githubusercontent.com/125879861/255389999-a0d261cf-893a-46a7-9a3d-2bb52811b997.png" alt="Open In Colab" width="200px">
|
|
240
|
+
</a>
|
|
241
|
+
|
|
242
|
+
The notebook walks you through **5 steps**:
|
|
243
|
+
|
|
244
|
+
| Step | Name | What it does |
|
|
245
|
+
|:----:|:-----|:-------------|
|
|
246
|
+
| 1 | 📦 **Install & Load** | Installs PyGithub and loads the package |
|
|
247
|
+
| 2 | 🔌 **Test Connection** | Validates token, repo access, write permissions, API rate limit, and existing branches |
|
|
248
|
+
| 3 | 🔍 **Dry Run** | Preview what the bot will do — no changes made *(auto-fills from Step 2)* |
|
|
249
|
+
| 4 | 🚀 **Run for Real** | Create and merge PRs with full configuration *(auto-fills from Step 3)* |
|
|
250
|
+
| 5 | 🧹 **Cleanup** | Delete all auto-created branches *(auto-fills from Step 4)* |
|
|
251
|
+
|
|
252
|
+
> 💡 **Tip:** Enter your credentials once in Step 2 — they flow through to Steps 3, 4, and 5 automatically. Run the styling cell first to enable dark/light mode support.
|
|
253
|
+
|
|
254
|
+
### 2️⃣ Command Line (CLI)
|
|
255
|
+
|
|
256
|
+
After installing with `pip install -e .`, use the `pullshark` command:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
# Create and merge PRs
|
|
260
|
+
pullshark run --token ghp_xxx --username YourUsername --repo YourRepo --prs 4
|
|
261
|
+
|
|
262
|
+
# Preview what would happen (no changes made)
|
|
263
|
+
pullshark run --token ghp_xxx --username YourUsername --repo YourRepo --dry-run
|
|
264
|
+
|
|
265
|
+
# Check API quota first
|
|
266
|
+
pullshark run --token ghp_xxx --username YourUsername --repo YourRepo --check-rate
|
|
267
|
+
|
|
268
|
+
# Use squash merge with custom branch prefix
|
|
269
|
+
pullshark run --token ghp_xxx --username YourUsername --repo YourRepo --merge-method squash --prefix mybot
|
|
270
|
+
|
|
271
|
+
# Save logs and JSON report
|
|
272
|
+
pullshark run --token ghp_xxx --username YourUsername --repo YourRepo --log run.log --output report.json
|
|
273
|
+
|
|
274
|
+
# Clean up auto-created branches
|
|
275
|
+
pullshark clean --token ghp_xxx --username YourUsername --repo YourRepo
|
|
276
|
+
|
|
277
|
+
# Preview cleanup without deleting
|
|
278
|
+
pullshark clean --token ghp_xxx --username YourUsername --repo YourRepo --dry-run
|
|
279
|
+
|
|
280
|
+
# Clean with custom prefix
|
|
281
|
+
pullshark clean --token ghp_xxx --username YourUsername --repo YourRepo --prefix mybot
|
|
282
|
+
|
|
283
|
+
# Run without installing
|
|
284
|
+
python -m pullshark run --token ghp_xxx --username YourUsername --repo YourRepo
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
> 💡 The `run` subcommand is optional — `pullshark --token ... --repo ...` still works as a shortcut.
|
|
288
|
+
|
|
289
|
+
#### CLI Arguments — `run`
|
|
290
|
+
|
|
291
|
+
| Flag | Short | Required | Default | Description |
|
|
292
|
+
|------|-------|:--------:|---------|-------------|
|
|
293
|
+
| `--token` | `-t` | ✅ | — | GitHub Personal Access Token |
|
|
294
|
+
| `--username` | `-u` | ✅ | — | Your GitHub username |
|
|
295
|
+
| `--repo` | `-r` | ✅ | — | Target repository name |
|
|
296
|
+
| `--prs` | `-n` | | `4` | Number of PRs to create |
|
|
297
|
+
| `--branch` | `-b` | | `main` | Base branch to target |
|
|
298
|
+
| `--delay` | `-d` | | `10` | Delay (seconds) between PRs |
|
|
299
|
+
| `--max-retries` | | | `3` | Max merge retry attempts |
|
|
300
|
+
| `--merge-method` | | | `merge` | Merge strategy: `merge`, `squash`, `rebase` |
|
|
301
|
+
| `--prefix` | | | `auto-pr` | Branch name prefix |
|
|
302
|
+
| `--dry-run` | | | off | Preview mode — no changes made |
|
|
303
|
+
| `--check-rate` | | | off | Show API quota before running |
|
|
304
|
+
| `--log` | | | — | Save detailed logs to a file |
|
|
305
|
+
| `--output` | | | — | Save run report as JSON |
|
|
306
|
+
|
|
307
|
+
#### CLI Arguments — `clean`
|
|
308
|
+
|
|
309
|
+
| Flag | Short | Required | Description |
|
|
310
|
+
|------|-------|:--------:|-------------|
|
|
311
|
+
| `--token` | `-t` | ✅ | GitHub Personal Access Token |
|
|
312
|
+
| `--username` | `-u` | ✅ | Your GitHub username |
|
|
313
|
+
| `--repo` | `-r` | ✅ | Target repository name |
|
|
314
|
+
| `--prefix` | | Branch prefix to clean (default: `auto-pr`) |
|
|
315
|
+
| `--dry-run` | | | Show branches that would be deleted without deleting |
|
|
316
|
+
| `--log` | | | Save detailed logs to a file |
|
|
317
|
+
|
|
318
|
+
#### CLI Output Example
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
Configuration: user='Shineii86' repo='PullShark'
|
|
322
|
+
Base branch: main
|
|
323
|
+
Will create 4 PR(s) with 10s delay.
|
|
324
|
+
Merge method: squash
|
|
325
|
+
Branch prefix: mybot
|
|
326
|
+
|
|
327
|
+
--- 📦 PR #1 of 4 ---
|
|
328
|
+
Latest main commit: a1b2c3d
|
|
329
|
+
✅ Created branch: mybot-xyz123-1234567890
|
|
330
|
+
📝 Updated README.md
|
|
331
|
+
🔗 Created PR: https://github.com/Shineii86/PullShark/pull/178
|
|
332
|
+
⏳ Waiting for GitHub to calculate mergeability...
|
|
333
|
+
🎉 Merged PR #178
|
|
334
|
+
⏸️ Pausing 10s for GitHub to process...
|
|
335
|
+
|
|
336
|
+
🏁 Finished. 4 out of 4 pull requests merged.
|
|
337
|
+
🦈 Congratulations! You've met the requirements for Pull Shark!
|
|
338
|
+
|
|
339
|
+
📄 Report saved to report.json
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
#### JSON Report Format
|
|
343
|
+
|
|
344
|
+
When using `--output report.json`, the file contains:
|
|
345
|
+
|
|
346
|
+
```json
|
|
347
|
+
{
|
|
348
|
+
"version": "2.4.6",
|
|
349
|
+
"timestamp": "2026-05-09T02:37:00+00:00",
|
|
350
|
+
"config": {
|
|
351
|
+
"username": "Shineii86",
|
|
352
|
+
"repo": "PullShark",
|
|
353
|
+
"base_branch": "main",
|
|
354
|
+
"num_prs": 4,
|
|
355
|
+
"merge_method": "squash",
|
|
356
|
+
"branch_prefix": "mybot"
|
|
357
|
+
},
|
|
358
|
+
"summary": {
|
|
359
|
+
"total": 4,
|
|
360
|
+
"successful": 4,
|
|
361
|
+
"failed": 0,
|
|
362
|
+
"pull_shark_tier": "Default"
|
|
363
|
+
},
|
|
364
|
+
"pull_requests": [
|
|
365
|
+
{"index": 1, "merged": true, "branch": "mybot-abc123", "pr_number": 178, "pr_url": "..."}
|
|
366
|
+
]
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### 3️⃣ As a Python Package
|
|
371
|
+
|
|
372
|
+
Import the bot into your own scripts for custom workflows:
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
from pullshark.config import Config
|
|
376
|
+
from pullshark.core import PullSharkBot
|
|
377
|
+
|
|
378
|
+
config = Config(
|
|
379
|
+
github_username="YourUsername",
|
|
380
|
+
github_token="ghp_xxx",
|
|
381
|
+
repo_name="YourRepo",
|
|
382
|
+
num_prs=6,
|
|
383
|
+
delay_seconds=15,
|
|
384
|
+
merge_method="squash",
|
|
385
|
+
branch_prefix="mybot",
|
|
386
|
+
output_file="report.json", # Save JSON report
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
bot = PullSharkBot(config)
|
|
390
|
+
merged = bot.run()
|
|
391
|
+
print(f"Merged {merged} PRs")
|
|
392
|
+
|
|
393
|
+
# Clean up branches when done
|
|
394
|
+
bot.clean()
|
|
395
|
+
|
|
396
|
+
# Check API quota
|
|
397
|
+
info = bot.check_rate_limit()
|
|
398
|
+
print(f"API: {info['remaining']}/{info['limit']} remaining")
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## ⚙️ Configuration Options
|
|
404
|
+
|
|
405
|
+
| Parameter | Default | Description |
|
|
406
|
+
|:----------|:--------|:------------|
|
|
407
|
+
| `num_prs` | `4` | Total number of pull requests to create and merge. Minimum `2` for the badge. |
|
|
408
|
+
| `base_branch` | `"main"` | Target branch for PRs (e.g., `master`, `develop`). |
|
|
409
|
+
| `delay_seconds` | `10` | Wait time (in seconds) between PRs and between merge retries. |
|
|
410
|
+
| `max_retries` | `3` | Number of times to retry a failed merge before stopping. |
|
|
411
|
+
| `merge_method` | `"merge"` | Merge strategy: `"merge"`, `"squash"`, or `"rebase"`. |
|
|
412
|
+
| `branch_prefix` | `"auto-pr"` | Prefix for auto-generated branch names. |
|
|
413
|
+
| `dry_run` | `False` | If `True`, previews actions without making changes. |
|
|
414
|
+
| `log_file` | `""` | Path to save detailed debug logs. |
|
|
415
|
+
| `output_file` | `""` | Path to save JSON run report. |
|
|
416
|
+
|
|
417
|
+
### Advanced Customization
|
|
418
|
+
|
|
419
|
+
- **File Modified**: By default, the script updates or creates `README.md`. To change this, edit `_make_commit()` in `pullshark/core.py`.
|
|
420
|
+
- **Repository**: Use any repo you own or have write access to — just update the repo name.
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## 🔬 How It Works (Technical Overview)
|
|
425
|
+
|
|
426
|
+
The script performs the following steps for **each** pull request:
|
|
427
|
+
|
|
428
|
+
1. **Fetch the latest commit SHA** from the specified base branch to ensure we branch from the most up‑to‑date state.
|
|
429
|
+
2. **Create a new branch** with a unique name (e.g., `auto-pr-abc123-1234567890`).
|
|
430
|
+
3. **Make a commit** on that branch — either appending a line to `README.md` or creating it if it doesn't exist.
|
|
431
|
+
4. **Open a pull request** from the new branch to the base branch.
|
|
432
|
+
5. **Wait for GitHub's mergeability check** (polling the PR status every 3 seconds).
|
|
433
|
+
6. **Merge the pull request** using the configured merge method.
|
|
434
|
+
7. **Pause for `DELAY_SECONDS`** to let GitHub fully update the base branch before starting the next iteration.
|
|
435
|
+
|
|
436
|
+
**Retry Logic**: If a merge fails (e.g., due to a temporary GitHub hiccup), the script will wait `DELAY_SECONDS` and retry up to `MAX_RETRIES` times before giving up.
|
|
437
|
+
|
|
438
|
+
**Rate Limit Check**: With `--check-rate`, the bot inspects your remaining API quota before starting. Each PR cycle uses ~4 API calls, so the bot estimates whether you have enough.
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## 🔀 Merge Methods
|
|
443
|
+
|
|
444
|
+
PullShark supports three merge strategies. Choose based on your preference:
|
|
445
|
+
|
|
446
|
+
| Method | Flag | What It Does | History |
|
|
447
|
+
|:-------|:-----|:-------------|:--------|
|
|
448
|
+
| **Merge** | `--merge-method merge` | Creates a merge commit joining the branch to base | Preserves all commits |
|
|
449
|
+
| **Squash** | `--merge-method squash` | Combines all branch commits into a single commit | Clean, linear history |
|
|
450
|
+
| **Rebase** | `--merge-method rebase` | Replays branch commits on top of base | Linear, no merge commit |
|
|
451
|
+
|
|
452
|
+
> 💡 For Pull Shark achievement purposes, all three methods count equally. Use `squash` for cleaner history.
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## 🧪 Testing & Contributing
|
|
457
|
+
|
|
458
|
+
### Running Tests
|
|
459
|
+
|
|
460
|
+
```bash
|
|
461
|
+
# Install dev dependencies
|
|
462
|
+
pip install -e ".[dev]"
|
|
463
|
+
|
|
464
|
+
# Run all tests
|
|
465
|
+
pytest tests/ -v
|
|
466
|
+
|
|
467
|
+
# Run with coverage
|
|
468
|
+
pytest tests/ -v --cov=pullshark --cov-report=term-missing
|
|
469
|
+
|
|
470
|
+
# Lint
|
|
471
|
+
ruff check pullshark/ tests/
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Pre-Commit Hooks
|
|
475
|
+
|
|
476
|
+
For contributors, pre-commit hooks run automatically before each commit:
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
pip install pre-commit
|
|
480
|
+
pre-commit install
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Hooks include:
|
|
484
|
+
- **ruff** — lint and auto-format Python code
|
|
485
|
+
- **trailing-whitespace** — remove trailing spaces
|
|
486
|
+
- **end-of-file-fixer** — ensure files end with newline
|
|
487
|
+
- **check-yaml / check-json** — validate config files
|
|
488
|
+
- **pytest** — run the test suite
|
|
489
|
+
|
|
490
|
+
### CI Pipeline
|
|
491
|
+
|
|
492
|
+
Every push and PR triggers the GitHub Actions workflow which:
|
|
493
|
+
- Runs the test suite across Python 3.9, 3.10, 3.11, and 3.12
|
|
494
|
+
- Lints code with ruff
|
|
495
|
+
- Validates notebook JSON structure
|
|
496
|
+
|
|
497
|
+
### Publishing (Maintainers)
|
|
498
|
+
|
|
499
|
+
Tag a release to auto-publish to PyPI:
|
|
500
|
+
|
|
501
|
+
```bash
|
|
502
|
+
git tag v2.4.6
|
|
503
|
+
git push origin v2.4.6
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
The `publish.yml` workflow builds and publishes automatically via trusted publishing.
|
|
507
|
+
|
|
508
|
+
### Contributing
|
|
509
|
+
|
|
510
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, branch naming conventions, and PR guidelines.
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## 🆘 Troubleshooting
|
|
515
|
+
|
|
516
|
+
| Issue | Solution |
|
|
517
|
+
|:------|:---------|
|
|
518
|
+
| `BadCredentialsException` | Token is wrong or expired. Generate a new one with `repo` scope. |
|
|
519
|
+
| `405 Not mergeable` | Enable **Allow auto-merge** in repo Settings → General → Pull Requests. |
|
|
520
|
+
| Hangs at "Waiting for mergeability" | PR may have a conflict. Delete the branch manually and retry. |
|
|
521
|
+
| `RateLimitExceededException` | Wait an hour or increase `DELAY_SECONDS`. Use `--check-rate` to check beforehand. |
|
|
522
|
+
| No badge after success | Wait a few minutes and refresh your profile. Achievement updates are not always instant. |
|
|
523
|
+
| Leftover branches | Run `pullshark clean` to delete all auto-created branches. |
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## 📋 Changelog
|
|
528
|
+
|
|
529
|
+
See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
|
|
530
|
+
|
|
531
|
+
---
|
|
532
|
+
|
|
533
|
+
## 📄 License & Disclaimer
|
|
534
|
+
|
|
535
|
+
This project is licensed under the **MIT License** – see the [LICENSE](LICENSE) file for details.
|
|
536
|
+
|
|
537
|
+
> [!WARNING]
|
|
538
|
+
> **This script is intended for educational purposes and to help users unlock a harmless GitHub achievement**. Please use responsibly and do not abuse automation to spam repositories. The author is not responsible for any consequences arising from misuse of this tool.
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
### 🔗 Quick Links
|
|
543
|
+
|
|
544
|
+
- [PyPI Package](https://pypi.org/project/pullshark/)
|
|
545
|
+
- [Google Colab](https://colab.research.google.com/)
|
|
546
|
+
- [GitHub Personal Access Tokens](https://github.com/settings/tokens)
|
|
547
|
+
- [Fine-Grained Tokens](https://github.com/settings/personal-access-tokens/new)
|
|
548
|
+
- [Pull Shark Achievement Details](https://github.com/Schweinepriester/github-profile-achievements#pull-shark-)
|
|
549
|
+
- [Contributing Guide](CONTRIBUTING.md)
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## 💕 Loved My Work?
|
|
554
|
+
|
|
555
|
+
🚨 [Follow me on GitHub](https://github.com/Shineii86)
|
|
556
|
+
|
|
557
|
+
⭐ [Give a star to this project](https://github.com/Shineii86/PullShark)
|
|
558
|
+
|
|
559
|
+
<div align="center">
|
|
560
|
+
|
|
561
|
+
<a href="https://github.com/Shineii86/PullShark">
|
|
562
|
+
<img src="https://github.com/Shineii86/AniPay/blob/main/Source/Banner6.png" alt="Banner">
|
|
563
|
+
</a>
|
|
564
|
+
|
|
565
|
+
*For inquiries or collaborations*
|
|
566
|
+
|
|
567
|
+
[](https://telegram.me/Shineii86 "Contact on Telegram")
|
|
568
|
+
[](https://instagram.com/ikx7.a "Follow on Instagram")
|
|
569
|
+
[](https://pinterest.com/ikx7a "Follow on Pinterest")
|
|
570
|
+
[](mailto:ikx7a@hotmail.com "Send an Email")
|
|
571
|
+
|
|
572
|
+
<sup><b>Copyright © 2026 <a href="https://telegram.me/Shineii86">Shinei Nouzen</a> All Rights Reserved</b></sup>
|
|
573
|
+
|
|
574
|
+

|
|
575
|
+
|
|
576
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pullshark/__init__.py,sha256=JXQeEkUcLeUrzm0Mbtg1dS3KvIAws_waU-p7OD1BRqU,281
|
|
2
|
+
pullshark/__main__.py,sha256=xsJtyjvV0ffLzxr9eeJC4p2q--Nsz2wQUhody7x16vQ,117
|
|
3
|
+
pullshark/cli.py,sha256=b89FmJQHB_Q0tGzJ542lFYwkSy-CECSy6tLlMDVYZZY,5966
|
|
4
|
+
pullshark/config.py,sha256=IdKtlBAFi6oBWXZfxx2mH_-QmE4Y5ooYTk0z2mNzvKI,1490
|
|
5
|
+
pullshark/core.py,sha256=yQMiTnEOuKrovQvVPgkbx-yJGwBejtCPgGBQ-tDwPys,11320
|
|
6
|
+
pullshark/utils.py,sha256=_JeHI_lfMD6YFasWYaHn7dhqIfAOgltkx9guNpQ-y8o,3695
|
|
7
|
+
pullshark-2.4.6.dist-info/licenses/LICENSE,sha256=eCBJvwCqBquo4KyGHqgJU0DJx1oLbCu2Qf6paQplLmc,1085
|
|
8
|
+
pullshark-2.4.6.dist-info/METADATA,sha256=4N6Uwv4lYjT4Llv6pemBpqsZ_kX8S-5bUUqs2rsfdww,22624
|
|
9
|
+
pullshark-2.4.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
pullshark-2.4.6.dist-info/entry_points.txt,sha256=S3ld0U4igqff8HuBysnCQki8v-Xzz0-efYZtP-iJKSE,49
|
|
11
|
+
pullshark-2.4.6.dist-info/top_level.txt,sha256=whBUtyk3d4NqZVO-Xmk2dpIO0-ORewLXQHwq4C-gL4o,10
|
|
12
|
+
pullshark-2.4.6.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pullshark
|