commitrocket 0.1.1__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.
- commitrocket/__init__.py +3 -0
- commitrocket/__main__.py +3 -0
- commitrocket/batching.py +184 -0
- commitrocket/cli.py +431 -0
- commitrocket/config.py +125 -0
- commitrocket/doctor.py +202 -0
- commitrocket/errors.py +14 -0
- commitrocket/eta.py +40 -0
- commitrocket/filters.py +72 -0
- commitrocket/git.py +228 -0
- commitrocket/journal.py +31 -0
- commitrocket/lfs.py +212 -0
- commitrocket/logs.py +67 -0
- commitrocket/notify.py +162 -0
- commitrocket/options.py +38 -0
- commitrocket/push.py +246 -0
- commitrocket/report.py +130 -0
- commitrocket/runner.py +680 -0
- commitrocket/security.py +50 -0
- commitrocket/template.py +37 -0
- commitrocket/ui.py +216 -0
- commitrocket/verify.py +129 -0
- commitrocket-0.1.1.dist-info/METADATA +520 -0
- commitrocket-0.1.1.dist-info/RECORD +27 -0
- commitrocket-0.1.1.dist-info/WHEEL +4 -0
- commitrocket-0.1.1.dist-info/entry_points.txt +2 -0
- commitrocket-0.1.1.dist-info/licenses/LICENSE +21 -0
commitrocket/journal.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from commitrocket.git import run_git
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Journal:
|
|
8
|
+
"""Remembers the batch being staged so an interrupted run can be resumed.
|
|
9
|
+
|
|
10
|
+
It lives inside .git, is written before `git add` and removed once the commit
|
|
11
|
+
exists. Staged files that match it are ours; any other staged file is not.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, repo):
|
|
15
|
+
relative = run_git(
|
|
16
|
+
repo, "rev-parse", "--git-path", "commitrocket-journal.json"
|
|
17
|
+
).stdout.strip()
|
|
18
|
+
self.path = Path(repo) / relative
|
|
19
|
+
|
|
20
|
+
def read(self):
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
23
|
+
except (OSError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
def write(self, files, message, number):
|
|
27
|
+
entry = {"files": list(files), "message": message, "number": number}
|
|
28
|
+
self.path.write_text(json.dumps(entry), encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
def clear(self):
|
|
31
|
+
self.path.unlink(missing_ok=True)
|
commitrocket/lfs.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
from commitrocket.errors import CommitRocketError
|
|
9
|
+
from commitrocket.git import get_file_size, run_git
|
|
10
|
+
from commitrocket.security import has_control_chars
|
|
11
|
+
|
|
12
|
+
# Git hosts reject pushes containing files larger than this.
|
|
13
|
+
HOST_LIMIT_MIB = 100
|
|
14
|
+
|
|
15
|
+
# Files above this size go through LFS by default (--lfs-threshold-mib; 0 = all).
|
|
16
|
+
LFS_THRESHOLD_MIB = HOST_LIMIT_MIB
|
|
17
|
+
|
|
18
|
+
# Files that must stay ordinary blobs even when every file goes through LFS.
|
|
19
|
+
LFS_EXCLUDED = (".gitattributes", ".gitignore", ".lfsconfig")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_lfs_candidates(repo, files, threshold_bytes, max_bytes):
|
|
23
|
+
"""Files above the LFS threshold that are still within the max file size."""
|
|
24
|
+
return [
|
|
25
|
+
path
|
|
26
|
+
for path in files
|
|
27
|
+
if threshold_bytes < get_file_size(repo, path) <= max_bytes
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def attribute_line(path):
|
|
32
|
+
"""A .gitattributes-style rule that sends exactly this file through LFS."""
|
|
33
|
+
if has_control_chars(path):
|
|
34
|
+
# A newline here would let a file name add rules of its own.
|
|
35
|
+
raise CommitRocketError(
|
|
36
|
+
f"Cannot create an LFS rule for {path!r}: the file name contains "
|
|
37
|
+
"control characters. Rename the file and try again."
|
|
38
|
+
)
|
|
39
|
+
glob_escaped = re.sub(r"([*?\[\]\\])", r"\\\1", path)
|
|
40
|
+
quoted = glob_escaped.replace("\\", "\\\\").replace('"', '\\"')
|
|
41
|
+
return f'"/{quoted}" filter=lfs diff=lfs merge=lfs -text'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def committed_rule_lines(paths):
|
|
45
|
+
"""Rules for .gitattributes: one per file extension, exact path if it has none.
|
|
46
|
+
|
|
47
|
+
Extension rules also cover future files of the same type.
|
|
48
|
+
"""
|
|
49
|
+
lines = set()
|
|
50
|
+
for path in paths:
|
|
51
|
+
pure = PurePosixPath(path)
|
|
52
|
+
if pure.name in LFS_EXCLUDED:
|
|
53
|
+
continue
|
|
54
|
+
suffix = pure.suffix
|
|
55
|
+
if suffix and not re.search(r"[\s*?\[\]\\\"]", suffix):
|
|
56
|
+
lines.add(f"*{suffix} filter=lfs diff=lfs merge=lfs -text")
|
|
57
|
+
else:
|
|
58
|
+
lines.add(attribute_line(path))
|
|
59
|
+
return sorted(lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def all_files_rules():
|
|
63
|
+
"""Rules sending every file through LFS, except the Git config files."""
|
|
64
|
+
rules = ["* filter=lfs diff=lfs merge=lfs -text"]
|
|
65
|
+
rules += [f"{name} !filter !diff !merge !text" for name in LFS_EXCLUDED]
|
|
66
|
+
return rules
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_lfs_available():
|
|
70
|
+
result = subprocess.run(["git", "lfs", "version"], capture_output=True)
|
|
71
|
+
return result.returncode == 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def install_command():
|
|
75
|
+
"""Package-manager command that installs git-lfs, or None if unsupported."""
|
|
76
|
+
if sys.platform == "win32" and shutil.which("winget"):
|
|
77
|
+
return [
|
|
78
|
+
"winget",
|
|
79
|
+
"install",
|
|
80
|
+
"--id",
|
|
81
|
+
"GitHub.GitLFS",
|
|
82
|
+
"-e",
|
|
83
|
+
"--accept-source-agreements",
|
|
84
|
+
"--accept-package-agreements",
|
|
85
|
+
]
|
|
86
|
+
if sys.platform == "darwin" and shutil.which("brew"):
|
|
87
|
+
return ["brew", "install", "git-lfs"]
|
|
88
|
+
if sys.platform.startswith("linux") and shutil.which("apt-get"):
|
|
89
|
+
command = ["apt-get", "install", "-y", "git-lfs"]
|
|
90
|
+
if os.geteuid() != 0 and shutil.which("sudo"):
|
|
91
|
+
command.insert(0, "sudo")
|
|
92
|
+
return command
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ensure_git_lfs(console, force):
|
|
97
|
+
"""Make sure the git-lfs binary is available, installing it if needed."""
|
|
98
|
+
if is_lfs_available():
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
command = install_command()
|
|
102
|
+
if command is None:
|
|
103
|
+
raise CommitRocketError(
|
|
104
|
+
"git-lfs is not installed and no supported package manager was "
|
|
105
|
+
"found. Install it from https://git-lfs.com and try again."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if not force:
|
|
109
|
+
answer = (
|
|
110
|
+
console.input(
|
|
111
|
+
"[yellow]git-lfs is not installed. Run "
|
|
112
|
+
f"'{' '.join(command)}'? [y/N][/yellow] "
|
|
113
|
+
)
|
|
114
|
+
.strip()
|
|
115
|
+
.lower()
|
|
116
|
+
)
|
|
117
|
+
if answer not in {"y", "yes"}:
|
|
118
|
+
raise CommitRocketError("git-lfs is required for --use-lfs.")
|
|
119
|
+
|
|
120
|
+
console.print("[bold cyan]Installing git-lfs...[/bold cyan]")
|
|
121
|
+
if subprocess.run(command).returncode != 0:
|
|
122
|
+
raise CommitRocketError("Installing git-lfs failed.")
|
|
123
|
+
|
|
124
|
+
if not is_lfs_available():
|
|
125
|
+
raise CommitRocketError(
|
|
126
|
+
"git-lfs was installed but is not on PATH yet. "
|
|
127
|
+
"Restart your terminal and run the command again."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class LfsSession:
|
|
132
|
+
"""Route large files through LFS for one run, with nothing committed.
|
|
133
|
+
|
|
134
|
+
The rules go into .git/info/attributes, which Git honours but never
|
|
135
|
+
commits, and are taken back out by close().
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, repo):
|
|
139
|
+
self.repo = repo
|
|
140
|
+
self.attributes_path = None
|
|
141
|
+
self.original = None
|
|
142
|
+
self.installed = False
|
|
143
|
+
|
|
144
|
+
def enable(self, paths, all_files=False):
|
|
145
|
+
"""Route paths through LFS, or every file when all_files is set."""
|
|
146
|
+
info_path = run_git(
|
|
147
|
+
self.repo, "rev-parse", "--git-path", "info/attributes"
|
|
148
|
+
).stdout.strip()
|
|
149
|
+
self.attributes_path = Path(self.repo) / info_path
|
|
150
|
+
self.original = (
|
|
151
|
+
self.attributes_path.read_bytes() if self.attributes_path.exists() else None
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self.install_hooks()
|
|
155
|
+
|
|
156
|
+
lines = all_files_rules() if all_files else map(attribute_line, paths)
|
|
157
|
+
rules = "".join(f"{line}\n" for line in lines)
|
|
158
|
+
existing = (self.original or b"").decode("utf-8", errors="replace")
|
|
159
|
+
if existing and not existing.endswith("\n"):
|
|
160
|
+
existing += "\n"
|
|
161
|
+
|
|
162
|
+
self.attributes_path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
self.attributes_path.write_text(existing + rules, encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
def install_hooks(self):
|
|
166
|
+
"""Install LFS in this repository only, so pushes upload LFS files."""
|
|
167
|
+
run_git(self.repo, "lfs", "install", "--local")
|
|
168
|
+
self.installed = True
|
|
169
|
+
|
|
170
|
+
def write_committed_rules(self, paths):
|
|
171
|
+
"""Append extension rules for paths to .gitattributes, to be committed.
|
|
172
|
+
|
|
173
|
+
Returns True if the file changed.
|
|
174
|
+
"""
|
|
175
|
+
dirty = run_git(
|
|
176
|
+
self.repo, "status", "--porcelain", "--", ".gitattributes"
|
|
177
|
+
).stdout.strip()
|
|
178
|
+
if dirty:
|
|
179
|
+
raise CommitRocketError(
|
|
180
|
+
".gitattributes has uncommitted changes. Commit or discard "
|
|
181
|
+
"them before using --lfs-commit-rules."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
target = Path(self.repo) / ".gitattributes"
|
|
185
|
+
existing = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
186
|
+
known = {line.strip() for line in existing.splitlines()}
|
|
187
|
+
missing = [line for line in committed_rule_lines(paths) if line not in known]
|
|
188
|
+
if not missing:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
if existing and not existing.endswith("\n"):
|
|
192
|
+
existing += "\n"
|
|
193
|
+
rules = "".join(f"{line}\n" for line in missing)
|
|
194
|
+
target.write_text(existing + rules, encoding="utf-8")
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
def close(self, uninstall_hooks=True):
|
|
198
|
+
"""Restore the attributes file and remove the local LFS setup.
|
|
199
|
+
|
|
200
|
+
Pass uninstall_hooks=False to keep the hooks, which unpushed commits
|
|
201
|
+
containing LFS files still need to upload their data.
|
|
202
|
+
"""
|
|
203
|
+
if self.attributes_path is not None:
|
|
204
|
+
if self.original is None:
|
|
205
|
+
self.attributes_path.unlink(missing_ok=True)
|
|
206
|
+
else:
|
|
207
|
+
self.attributes_path.write_bytes(self.original)
|
|
208
|
+
self.attributes_path = None
|
|
209
|
+
|
|
210
|
+
if self.installed and uninstall_hooks:
|
|
211
|
+
run_git(self.repo, "lfs", "uninstall", "--local", check=False)
|
|
212
|
+
self.installed = False
|
commitrocket/logs.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from commitrocket.security import redact, restrict_permissions
|
|
8
|
+
|
|
9
|
+
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
|
|
10
|
+
DEFAULT_LEVEL = "INFO"
|
|
11
|
+
LOGGER_NAME = "commitrocket"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RedactingFormatter(logging.Formatter):
|
|
15
|
+
"""Hides credentials embedded in URLs, including in tracebacks."""
|
|
16
|
+
|
|
17
|
+
def format(self, record):
|
|
18
|
+
return redact(super().format(record))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_log_dir():
|
|
22
|
+
"""The conventional per-user log folder for the current OS."""
|
|
23
|
+
home = Path.home()
|
|
24
|
+
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
base = Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local")
|
|
27
|
+
return base / "CommitRocket" / "Logs"
|
|
28
|
+
if sys.platform == "darwin":
|
|
29
|
+
return home / "Library" / "Logs" / "CommitRocket"
|
|
30
|
+
|
|
31
|
+
state = os.environ.get("XDG_STATE_HOME")
|
|
32
|
+
return (Path(state) if state else home / ".local" / "state") / "commitrocket"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def setup_logging(level=DEFAULT_LEVEL, log_file=None):
|
|
36
|
+
"""Log to a file for this run. Returns its path, or None if it can't be opened."""
|
|
37
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
38
|
+
logger.setLevel(getattr(logging, level.upper()))
|
|
39
|
+
logger.propagate = False
|
|
40
|
+
close_logging()
|
|
41
|
+
|
|
42
|
+
if log_file:
|
|
43
|
+
path = Path(log_file).expanduser()
|
|
44
|
+
else:
|
|
45
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
46
|
+
path = default_log_dir() / f"commitrocket-{stamp}.log"
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
handler = logging.FileHandler(path, encoding="utf-8")
|
|
51
|
+
except OSError:
|
|
52
|
+
logger.addHandler(logging.NullHandler())
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
restrict_permissions(path)
|
|
56
|
+
handler.setFormatter(
|
|
57
|
+
RedactingFormatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s")
|
|
58
|
+
)
|
|
59
|
+
logger.addHandler(handler)
|
|
60
|
+
return path.resolve()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def close_logging():
|
|
64
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
65
|
+
for handler in list(logger.handlers):
|
|
66
|
+
handler.close()
|
|
67
|
+
logger.removeHandler(handler)
|
commitrocket/notify.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from commitrocket.eta import format_duration
|
|
12
|
+
from commitrocket.report import (
|
|
13
|
+
ABORTED,
|
|
14
|
+
CANCELLED,
|
|
15
|
+
DEFERRED,
|
|
16
|
+
FAILED,
|
|
17
|
+
INTERRUPTED,
|
|
18
|
+
SUCCESS,
|
|
19
|
+
)
|
|
20
|
+
from commitrocket.security import redact
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Windows PowerShell's own AppUserModelID, which Windows already trusts to show toasts.
|
|
25
|
+
WINDOWS_APP_ID = (
|
|
26
|
+
"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"
|
|
27
|
+
)
|
|
28
|
+
WINDOWS_SCRIPT = f"""
|
|
29
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
30
|
+
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
31
|
+
$text = $xml.GetElementsByTagName('text')
|
|
32
|
+
$text.Item(0).AppendChild($xml.CreateTextNode($env:CR_TITLE)) | Out-Null
|
|
33
|
+
$text.Item(1).AppendChild($xml.CreateTextNode($env:CR_BODY)) | Out-Null
|
|
34
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
35
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{WINDOWS_APP_ID}').Show($toast)
|
|
36
|
+
""" # noqa: E501
|
|
37
|
+
MACOS_SCRIPT = (
|
|
38
|
+
'display notification (system attribute "CR_BODY") '
|
|
39
|
+
'with title (system attribute "CR_TITLE")'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
DESKTOP_TIMEOUT = 15
|
|
43
|
+
WEBHOOK_TIMEOUT = 10
|
|
44
|
+
|
|
45
|
+
HEADLINES = {
|
|
46
|
+
SUCCESS: "finished",
|
|
47
|
+
DEFERRED: "finished, some files were skipped",
|
|
48
|
+
CANCELLED: "cancelled",
|
|
49
|
+
ABORTED: "stopped before starting",
|
|
50
|
+
INTERRUPTED: "interrupted",
|
|
51
|
+
FAILED: "failed",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_message(report):
|
|
56
|
+
"""(title, body) describing how the run ended."""
|
|
57
|
+
title = f"CommitRocket {HEADLINES.get(report.status, report.status)}"
|
|
58
|
+
|
|
59
|
+
parts = []
|
|
60
|
+
if report.batches:
|
|
61
|
+
mib = report.total_bytes / (1024 * 1024)
|
|
62
|
+
verb = "committed" if report.settings.get("no_push") else "pushed"
|
|
63
|
+
parts.append(
|
|
64
|
+
f"{len(report.batches)} batch(es) {verb}, {report.total_files} file(s), "
|
|
65
|
+
f"{mib:.1f} MiB"
|
|
66
|
+
)
|
|
67
|
+
if report.deferred:
|
|
68
|
+
parts.append(f"{len(report.deferred)} file(s) skipped for size")
|
|
69
|
+
if report.error:
|
|
70
|
+
parts.append(report.error.strip().splitlines()[0])
|
|
71
|
+
if report.target:
|
|
72
|
+
parts.append(f"to {report.target}")
|
|
73
|
+
if report.duration_seconds is not None:
|
|
74
|
+
parts.append(f"in {format_duration(report.duration_seconds)}")
|
|
75
|
+
|
|
76
|
+
return title, redact("; ".join(parts) or "No changes were made.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run_quietly(command, env):
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
command,
|
|
82
|
+
env={**os.environ, **env},
|
|
83
|
+
stdout=subprocess.DEVNULL,
|
|
84
|
+
stderr=subprocess.DEVNULL,
|
|
85
|
+
timeout=DESKTOP_TIMEOUT,
|
|
86
|
+
)
|
|
87
|
+
return result.returncode == 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def notify_desktop(title, body):
|
|
91
|
+
"""Show a native notification. Returns the method used; 'bell' if none worked."""
|
|
92
|
+
env = {"CR_TITLE": title, "CR_BODY": body}
|
|
93
|
+
try:
|
|
94
|
+
if sys.platform == "win32" and shutil.which("powershell"):
|
|
95
|
+
command = ["powershell", "-NoProfile", "-NonInteractive", "-Command"]
|
|
96
|
+
if _run_quietly(command + [WINDOWS_SCRIPT], env):
|
|
97
|
+
return "windows-toast"
|
|
98
|
+
elif sys.platform == "darwin" and shutil.which("osascript"):
|
|
99
|
+
if _run_quietly(["osascript", "-e", MACOS_SCRIPT], env):
|
|
100
|
+
return "macos-notification"
|
|
101
|
+
elif shutil.which("notify-send"):
|
|
102
|
+
command = ["notify-send", "--app-name=CommitRocket", title, body]
|
|
103
|
+
if _run_quietly(command, env):
|
|
104
|
+
return "notify-send"
|
|
105
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
106
|
+
logger.warning("Desktop notification failed: %s", exc)
|
|
107
|
+
|
|
108
|
+
sys.stdout.write("\a")
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
return "bell"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_valid_webhook(url):
|
|
114
|
+
parsed = urlparse(url)
|
|
115
|
+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def notify_webhook(url, title, body, report):
|
|
119
|
+
"""POST a JSON summary. 'text' and 'content' suit Slack/Teams and Discord."""
|
|
120
|
+
payload = {
|
|
121
|
+
"text": f"{title}: {body}",
|
|
122
|
+
"content": f"{title}: {body}",
|
|
123
|
+
"title": title,
|
|
124
|
+
"message": body,
|
|
125
|
+
"status": report.status,
|
|
126
|
+
"exit_code": report.exit_code,
|
|
127
|
+
"repo": report.repo,
|
|
128
|
+
"target": redact(report.target) if report.target else None,
|
|
129
|
+
"batches": len(report.batches),
|
|
130
|
+
"files": report.total_files,
|
|
131
|
+
"size_bytes": report.total_bytes,
|
|
132
|
+
"duration_seconds": report.duration_seconds,
|
|
133
|
+
}
|
|
134
|
+
request = urllib.request.Request(
|
|
135
|
+
url,
|
|
136
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
137
|
+
headers={
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
"Title": title.encode("ascii", "replace").decode("ascii"),
|
|
140
|
+
"User-Agent": "commitrocket",
|
|
141
|
+
},
|
|
142
|
+
method="POST",
|
|
143
|
+
)
|
|
144
|
+
with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT) as response:
|
|
145
|
+
return response.status
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def send_notifications(console, report, desktop=False, webhook=None):
|
|
149
|
+
"""Notify about the finished run. Failures are reported but never raised."""
|
|
150
|
+
title, body = build_message(report)
|
|
151
|
+
|
|
152
|
+
if desktop:
|
|
153
|
+
method = notify_desktop(title, body)
|
|
154
|
+
logger.info("Desktop notification sent via %s", method)
|
|
155
|
+
|
|
156
|
+
if webhook:
|
|
157
|
+
try:
|
|
158
|
+
status = notify_webhook(webhook, title, body, report)
|
|
159
|
+
logger.info("Webhook notified (HTTP %s)", status)
|
|
160
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
161
|
+
logger.warning("Webhook notification failed: %s", exc)
|
|
162
|
+
console.print(f"[yellow]Could not notify the webhook: {exc}[/yellow]")
|
commitrocket/options.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from commitrocket.batching import DETERMINISTIC
|
|
4
|
+
from commitrocket.lfs import LFS_THRESHOLD_MIB
|
|
5
|
+
from commitrocket.template import DEFAULT_TEMPLATE
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RunOptions:
|
|
10
|
+
"""Everything a run needs, as parsed from the command line and config file."""
|
|
11
|
+
|
|
12
|
+
repo: str = "."
|
|
13
|
+
message: str = ""
|
|
14
|
+
number: int = 1
|
|
15
|
+
force: bool = False
|
|
16
|
+
limit_mib: int = 100
|
|
17
|
+
max_mib: int = 300
|
|
18
|
+
batch_type: str = DETERMINISTIC
|
|
19
|
+
message_template: str = DEFAULT_TEMPLATE
|
|
20
|
+
remote: str | None = None
|
|
21
|
+
branch: str | None = None
|
|
22
|
+
no_push: bool = False
|
|
23
|
+
dry_run: bool = False
|
|
24
|
+
retries: int = 3
|
|
25
|
+
post_buffer_mib: int | None = None
|
|
26
|
+
compression_threads: int | None = None
|
|
27
|
+
use_lfs: bool = False
|
|
28
|
+
lfs_threshold_mib: int = LFS_THRESHOLD_MIB
|
|
29
|
+
lfs_threads: int | None = None
|
|
30
|
+
lfs_batch_mib: int | None = None
|
|
31
|
+
lfs_commit_rules: bool = False
|
|
32
|
+
include: list = field(default_factory=list)
|
|
33
|
+
exclude: list = field(default_factory=list)
|
|
34
|
+
include_from: list = field(default_factory=list)
|
|
35
|
+
exclude_from: list = field(default_factory=list)
|
|
36
|
+
# Files the run itself writes; they are never committed, even inside the repo.
|
|
37
|
+
log_file: str | None = None
|
|
38
|
+
report: str | None = None
|