tripwire-git 0.1.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.
- tripwire/__init__.py +3 -0
- tripwire/__main__.py +5 -0
- tripwire/approvals.py +30 -0
- tripwire/blocks.py +41 -0
- tripwire/cli.py +244 -0
- tripwire/config.py +82 -0
- tripwire/engine.py +77 -0
- tripwire/gitea.py +71 -0
- tripwire/github.py +64 -0
- tripwire/gitio.py +76 -0
- tripwire/gitlab.py +66 -0
- tripwire/httpjson.py +34 -0
- tripwire/models.py +35 -0
- tripwire/review.py +33 -0
- tripwire_git-0.1.0.dist-info/METADATA +155 -0
- tripwire_git-0.1.0.dist-info/RECORD +20 -0
- tripwire_git-0.1.0.dist-info/WHEEL +5 -0
- tripwire_git-0.1.0.dist-info/entry_points.txt +2 -0
- tripwire_git-0.1.0.dist-info/licenses/LICENSE +21 -0
- tripwire_git-0.1.0.dist-info/top_level.txt +1 -0
tripwire/__init__.py
ADDED
tripwire/__main__.py
ADDED
tripwire/approvals.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Approval providers. Local provider reads .tripwire/approvals.json.
|
|
2
|
+
|
|
3
|
+
Platform providers (GitHub, GitLab, ...) will implement the same shape:
|
|
4
|
+
return a list of Approval for the change being checked.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from .config import ConfigError
|
|
13
|
+
from .models import Approval
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_local(text: Optional[str]) -> List[Approval]:
|
|
17
|
+
if not text or not text.strip():
|
|
18
|
+
return []
|
|
19
|
+
try:
|
|
20
|
+
raw = json.loads(text)
|
|
21
|
+
return [Approval(i["lock"], i["approver"], i["digest"]) for i in raw]
|
|
22
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
23
|
+
raise ConfigError("invalid approvals file: %s" % exc)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dump_local(approvals: List[Approval]) -> str:
|
|
27
|
+
return json.dumps(
|
|
28
|
+
[{"lock": a.lock_id, "approver": a.approver, "digest": a.digest} for a in approvals],
|
|
29
|
+
indent=2,
|
|
30
|
+
) + "\n"
|
tripwire/blocks.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, List, Tuple
|
|
5
|
+
|
|
6
|
+
BEGIN = re.compile(r"@lock-begin\s+([A-Za-z0-9_.\-]+)")
|
|
7
|
+
END = re.compile(r"@lock-end\s+([A-Za-z0-9_.\-]+)")
|
|
8
|
+
|
|
9
|
+
Norm = Tuple[str, ...]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize(text: str) -> Norm:
|
|
13
|
+
"""Strip each line and drop blank lines, so whitespace-only edits don't count."""
|
|
14
|
+
return tuple(line.strip() for line in text.splitlines() if line.strip())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_blocks(text: str) -> Dict[str, Norm]:
|
|
18
|
+
"""Map block id -> normalized content between its markers.
|
|
19
|
+
|
|
20
|
+
An unclosed block extends to the end of the file (fail closed).
|
|
21
|
+
"""
|
|
22
|
+
content: Dict[str, List[str]] = {}
|
|
23
|
+
open_ids: List[str] = []
|
|
24
|
+
for line in text.splitlines():
|
|
25
|
+
begin = BEGIN.search(line)
|
|
26
|
+
if begin:
|
|
27
|
+
open_ids.append(begin.group(1))
|
|
28
|
+
content.setdefault(begin.group(1), [])
|
|
29
|
+
continue
|
|
30
|
+
end = END.search(line)
|
|
31
|
+
if end:
|
|
32
|
+
if end.group(1) in open_ids:
|
|
33
|
+
open_ids.reverse()
|
|
34
|
+
open_ids.remove(end.group(1))
|
|
35
|
+
open_ids.reverse()
|
|
36
|
+
continue
|
|
37
|
+
stripped = line.strip()
|
|
38
|
+
if stripped:
|
|
39
|
+
for block_id in open_ids:
|
|
40
|
+
content[block_id].append(stripped)
|
|
41
|
+
return {k: tuple(v) for k, v in content.items()}
|
tripwire/cli.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from .approvals import dump_local, parse_local
|
|
10
|
+
from .config import APPROVALS_PATH, RULES_PATH, Config, ConfigError, parse_config
|
|
11
|
+
from .engine import evaluate
|
|
12
|
+
from .gitio import (
|
|
13
|
+
EMPTY_TREE,
|
|
14
|
+
ZERO,
|
|
15
|
+
GitError,
|
|
16
|
+
RevSource,
|
|
17
|
+
WorktreeSource,
|
|
18
|
+
collect_changes,
|
|
19
|
+
git,
|
|
20
|
+
new_branch_base,
|
|
21
|
+
toplevel,
|
|
22
|
+
)
|
|
23
|
+
from .models import Approval, Decision
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_actor(explicit: Optional[str], root: str) -> str:
|
|
27
|
+
for candidate in (
|
|
28
|
+
explicit,
|
|
29
|
+
os.environ.get("TRIPWIRE_ACTOR"),
|
|
30
|
+
os.environ.get("GL_USERNAME"),
|
|
31
|
+
os.environ.get("REMOTE_USER"),
|
|
32
|
+
):
|
|
33
|
+
if candidate:
|
|
34
|
+
return candidate
|
|
35
|
+
for key in ("tripwire.actor", "user.email"):
|
|
36
|
+
value = git(root, "config", key, check=False).decode().strip()
|
|
37
|
+
if value:
|
|
38
|
+
return value
|
|
39
|
+
raise ConfigError("cannot determine the acting user; pass --actor or set TRIPWIRE_ACTOR")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_check(root: str, base: str, head: Optional[str], actor: str) -> Tuple[Optional[Config], Decision]:
|
|
43
|
+
"""Rules are read from `base`, so a change cannot rewrite the rules that judge it."""
|
|
44
|
+
rules = RevSource(root, base).read(RULES_PATH)
|
|
45
|
+
if rules is None:
|
|
46
|
+
return None, Decision()
|
|
47
|
+
config = parse_config(rules)
|
|
48
|
+
head_src = RevSource(root, head) if head is not None else WorktreeSource(root)
|
|
49
|
+
approvals = parse_local(head_src.read(APPROVALS_PATH))
|
|
50
|
+
return config, evaluate(config, collect_changes(root, base, head), actor, approvals)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def report(decision: Decision) -> int:
|
|
54
|
+
if decision.allowed:
|
|
55
|
+
print("tripwire: OK")
|
|
56
|
+
return 0
|
|
57
|
+
print("tripwire: BLOCKED", file=sys.stderr)
|
|
58
|
+
for v in decision.violations:
|
|
59
|
+
print(" - " + v.message, file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_check(args, root: str) -> int:
|
|
64
|
+
actor = resolve_actor(args.actor, root)
|
|
65
|
+
_, decision = run_check(root, args.base, args.head, actor)
|
|
66
|
+
return report(decision)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_preflight(args, root: str) -> int:
|
|
70
|
+
actor = resolve_actor(args.actor, root)
|
|
71
|
+
has_head = git(root, "rev-parse", "--verify", "-q", "HEAD", check=False).strip()
|
|
72
|
+
_, decision = run_check(root, "HEAD" if has_head else EMPTY_TREE, "" if args.staged else None, actor)
|
|
73
|
+
return report(decision)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
HOOK_MARK = "# installed by tripwire"
|
|
77
|
+
HOOK = """#!/bin/sh
|
|
78
|
+
%s
|
|
79
|
+
# Local early warning. Bypassable with --no-verify: the server-side check is the real gate.
|
|
80
|
+
if command -v tripwire >/dev/null 2>&1; then
|
|
81
|
+
exec tripwire preflight --staged
|
|
82
|
+
else
|
|
83
|
+
exec python -m tripwire preflight --staged
|
|
84
|
+
fi
|
|
85
|
+
""" % HOOK_MARK
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_install_hook(args, root: str) -> int:
|
|
89
|
+
hooks = git(root, "rev-parse", "--git-path", "hooks").decode().strip()
|
|
90
|
+
path = os.path.join(root, hooks, "pre-commit") if not os.path.isabs(hooks) else os.path.join(hooks, "pre-commit")
|
|
91
|
+
if os.path.exists(path):
|
|
92
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
93
|
+
ours = HOOK_MARK in fh.read()
|
|
94
|
+
if not ours and not args.force:
|
|
95
|
+
print("tripwire: %s already exists; use --force to replace it" % path, file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
98
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
99
|
+
fh.write(HOOK)
|
|
100
|
+
os.chmod(path, 0o755)
|
|
101
|
+
print("tripwire: installed %s" % path)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_pre_receive(args, root: str) -> int:
|
|
106
|
+
actor = resolve_actor(args.actor, root)
|
|
107
|
+
status = 0
|
|
108
|
+
for line in sys.stdin:
|
|
109
|
+
parts = line.split()
|
|
110
|
+
if len(parts) < 3:
|
|
111
|
+
continue
|
|
112
|
+
old, new, ref = parts[:3]
|
|
113
|
+
if new == ZERO:
|
|
114
|
+
continue
|
|
115
|
+
base = old if old != ZERO else new_branch_base(root, new)
|
|
116
|
+
if base is None:
|
|
117
|
+
continue
|
|
118
|
+
_, decision = run_check(root, base, new, actor)
|
|
119
|
+
if not decision.allowed:
|
|
120
|
+
print("tripwire: push to %s rejected" % ref, file=sys.stderr)
|
|
121
|
+
status = report(decision) or status
|
|
122
|
+
return status
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _pull_number(explicit: Optional[int]) -> int:
|
|
126
|
+
if explicit is not None:
|
|
127
|
+
return explicit
|
|
128
|
+
event = os.environ.get("GITHUB_EVENT_PATH")
|
|
129
|
+
if not event or not os.path.isfile(event):
|
|
130
|
+
raise ConfigError("pull request number unknown; pass --pr")
|
|
131
|
+
with open(event, encoding="utf-8") as fh:
|
|
132
|
+
number = json.load(fh).get("pull_request", {}).get("number")
|
|
133
|
+
if number is None:
|
|
134
|
+
raise ConfigError("not a pull_request event; pass --pr")
|
|
135
|
+
return int(number)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_github_check(args, root: str) -> int:
|
|
139
|
+
from .github import GitHubClient, check_pull_request
|
|
140
|
+
|
|
141
|
+
return report(check_pull_request(root, GitHubClient.from_env(args.github_repo), _pull_number(args.pr)))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_gitea_check(args, root: str) -> int:
|
|
145
|
+
from .gitea import GiteaClient, check_pull_request
|
|
146
|
+
|
|
147
|
+
client = GiteaClient.from_env(args.gitea_repo, args.gitea_url)
|
|
148
|
+
return report(check_pull_request(root, client, _pull_number(args.pr)))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_gitlab_check(args, root: str) -> int:
|
|
152
|
+
from .gitlab import GitLabClient, check_merge_request
|
|
153
|
+
|
|
154
|
+
iid = args.mr or os.environ.get("CI_MERGE_REQUEST_IID")
|
|
155
|
+
if not iid:
|
|
156
|
+
raise ConfigError("merge request number unknown; pass --mr or run in a merge request pipeline")
|
|
157
|
+
return report(check_merge_request(root, GitLabClient.from_env(args.gitlab_project), int(iid)))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_approve(args, root: str) -> int:
|
|
161
|
+
config, decision = run_check(root, args.base, args.head, actor="")
|
|
162
|
+
if config is None:
|
|
163
|
+
print("tripwire: no rules file at base", file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
if args.approver not in config.everyone():
|
|
166
|
+
print("tripwire: %s is not an owner, approver or leader" % args.approver, file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
path = os.path.join(root, APPROVALS_PATH)
|
|
169
|
+
existing: List[Approval] = []
|
|
170
|
+
if os.path.isfile(path):
|
|
171
|
+
with open(path, encoding="utf-8") as fh:
|
|
172
|
+
existing = parse_local(fh.read())
|
|
173
|
+
added = 0
|
|
174
|
+
for v in decision.violations:
|
|
175
|
+
if v.digest and (args.lock is None or v.lock_id == args.lock):
|
|
176
|
+
lock = config.get(v.lock_id)
|
|
177
|
+
allowed = set(config.leaders) | (set(lock.owners) | set(lock.approvers) if lock else set())
|
|
178
|
+
if args.approver in allowed:
|
|
179
|
+
existing.append(Approval(v.lock_id, args.approver, v.digest))
|
|
180
|
+
added += 1
|
|
181
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
182
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
183
|
+
fh.write(dump_local(existing))
|
|
184
|
+
print("tripwire: recorded %d approval(s) in %s" % (added, APPROVALS_PATH))
|
|
185
|
+
return 0 if added else 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
189
|
+
p = argparse.ArgumentParser(prog="tripwire")
|
|
190
|
+
p.add_argument("--repo", default=".", help="path inside the repository")
|
|
191
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
192
|
+
|
|
193
|
+
c = sub.add_parser("check", help="check base..head (head defaults to the working tree)")
|
|
194
|
+
c.add_argument("--base", required=True)
|
|
195
|
+
c.add_argument("--head")
|
|
196
|
+
c.add_argument("--actor")
|
|
197
|
+
c.set_defaults(func=cmd_check)
|
|
198
|
+
|
|
199
|
+
f = sub.add_parser("preflight", help="check uncommitted changes against HEAD")
|
|
200
|
+
f.add_argument("--actor")
|
|
201
|
+
f.add_argument("--staged", action="store_true", help="check only staged changes (for pre-commit)")
|
|
202
|
+
f.set_defaults(func=cmd_preflight)
|
|
203
|
+
|
|
204
|
+
h = sub.add_parser("install-hook", help="install a local pre-commit hook")
|
|
205
|
+
h.add_argument("--force", action="store_true", help="replace an existing pre-commit hook")
|
|
206
|
+
h.set_defaults(func=cmd_install_hook)
|
|
207
|
+
|
|
208
|
+
r = sub.add_parser("pre-receive", help="server hook: read ref updates from stdin")
|
|
209
|
+
r.add_argument("--actor")
|
|
210
|
+
r.set_defaults(func=cmd_pre_receive)
|
|
211
|
+
|
|
212
|
+
g = sub.add_parser("github-check", help="check a GitHub pull request using its reviews")
|
|
213
|
+
g.add_argument("--pr", type=int, help="PR number (default: from GITHUB_EVENT_PATH)")
|
|
214
|
+
g.add_argument("--github-repo", help="owner/name (default: GITHUB_REPOSITORY)")
|
|
215
|
+
g.set_defaults(func=cmd_github_check)
|
|
216
|
+
|
|
217
|
+
gt = sub.add_parser("gitea-check", help="check a Gitea/Forgejo pull request using its reviews")
|
|
218
|
+
gt.add_argument("--pr", type=int, help="PR number (default: from GITHUB_EVENT_PATH)")
|
|
219
|
+
gt.add_argument("--gitea-repo", help="owner/name (default: GITHUB_REPOSITORY)")
|
|
220
|
+
gt.add_argument("--gitea-url", help="API base, e.g. https://git.example.com/api/v1")
|
|
221
|
+
gt.set_defaults(func=cmd_gitea_check)
|
|
222
|
+
|
|
223
|
+
gl = sub.add_parser("gitlab-check", help="check a GitLab merge request using its approvals")
|
|
224
|
+
gl.add_argument("--mr", type=int, help="MR iid (default: CI_MERGE_REQUEST_IID)")
|
|
225
|
+
gl.add_argument("--gitlab-project", help="project id or path (default: CI_PROJECT_ID)")
|
|
226
|
+
gl.set_defaults(func=cmd_gitlab_check)
|
|
227
|
+
|
|
228
|
+
a = sub.add_parser("approve", help="record an approval for the pending change")
|
|
229
|
+
a.add_argument("--base", required=True)
|
|
230
|
+
a.add_argument("--head")
|
|
231
|
+
a.add_argument("--approver", required=True)
|
|
232
|
+
a.add_argument("--lock", help="only approve this lock id")
|
|
233
|
+
a.set_defaults(func=cmd_approve)
|
|
234
|
+
return p
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
238
|
+
args = build_parser().parse_args(argv)
|
|
239
|
+
try:
|
|
240
|
+
root = toplevel(args.repo) if args.command != "pre-receive" else os.path.abspath(args.repo)
|
|
241
|
+
return args.func(args, root)
|
|
242
|
+
except (ConfigError, GitError) as exc:
|
|
243
|
+
print("tripwire: error: %s" % exc, file=sys.stderr)
|
|
244
|
+
return 2
|
tripwire/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
RULES_PATH = ".tripwire/locks.json"
|
|
9
|
+
APPROVALS_PATH = ".tripwire/approvals.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Lock:
|
|
18
|
+
id: str
|
|
19
|
+
owners: Tuple[str, ...] = ()
|
|
20
|
+
approvers: Tuple[str, ...] = ()
|
|
21
|
+
paths: Tuple[str, ...] = ()
|
|
22
|
+
|
|
23
|
+
def matches_path(self, path: str) -> bool:
|
|
24
|
+
for pat in self.paths:
|
|
25
|
+
if pat.endswith("/"):
|
|
26
|
+
if path.startswith(pat):
|
|
27
|
+
return True
|
|
28
|
+
elif fnmatch.fnmatchcase(path, pat):
|
|
29
|
+
return True
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Config:
|
|
35
|
+
leaders: Tuple[str, ...]
|
|
36
|
+
locks: Tuple[Lock, ...]
|
|
37
|
+
|
|
38
|
+
def get(self, lock_id: str) -> Optional[Lock]:
|
|
39
|
+
for lock in self.locks:
|
|
40
|
+
if lock.id == lock_id:
|
|
41
|
+
return lock
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def everyone(self) -> set:
|
|
45
|
+
people = set(self.leaders)
|
|
46
|
+
for lock in self.locks:
|
|
47
|
+
people.update(lock.owners)
|
|
48
|
+
people.update(lock.approvers)
|
|
49
|
+
return people
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _names(obj, key: str) -> Tuple[str, ...]:
|
|
53
|
+
value = obj.get(key, [])
|
|
54
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
55
|
+
raise ConfigError("'%s' must be a list of strings" % key)
|
|
56
|
+
return tuple(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_config(text: str) -> Config:
|
|
60
|
+
try:
|
|
61
|
+
raw = json.loads(text)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise ConfigError("invalid JSON in %s: %s" % (RULES_PATH, exc))
|
|
64
|
+
if not isinstance(raw, dict):
|
|
65
|
+
raise ConfigError("%s must be a JSON object" % RULES_PATH)
|
|
66
|
+
locks = []
|
|
67
|
+
seen = set()
|
|
68
|
+
for item in raw.get("locks", []):
|
|
69
|
+
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
|
70
|
+
raise ConfigError("every lock needs a string 'id'")
|
|
71
|
+
if item["id"] in seen:
|
|
72
|
+
raise ConfigError("duplicate lock id '%s'" % item["id"])
|
|
73
|
+
seen.add(item["id"])
|
|
74
|
+
locks.append(
|
|
75
|
+
Lock(
|
|
76
|
+
id=item["id"],
|
|
77
|
+
owners=_names(item, "owners"),
|
|
78
|
+
approvers=_names(item, "approvers"),
|
|
79
|
+
paths=_names(item, "paths"),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return Config(leaders=_names(raw, "leaders"), locks=tuple(locks))
|
tripwire/engine.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Platform-neutral core: diff + actor + rules + approvals -> allow / block."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Iterable, List, Optional
|
|
8
|
+
|
|
9
|
+
from .blocks import Norm, extract_blocks, normalize
|
|
10
|
+
from .config import RULES_PATH, Config, Lock
|
|
11
|
+
from .models import Approval, Decision, FileChange, Violation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def change_digest(lock_id: str, path: str, old: Optional[Norm], new: Optional[Norm]) -> str:
|
|
15
|
+
payload = json.dumps(
|
|
16
|
+
[lock_id, path, None if old is None else list(old), None if new is None else list(new)]
|
|
17
|
+
)
|
|
18
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _norm(text: Optional[str]) -> Optional[Norm]:
|
|
22
|
+
return None if text is None else normalize(text)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def evaluate(
|
|
26
|
+
config: Config,
|
|
27
|
+
changes: Iterable[FileChange],
|
|
28
|
+
actor: str,
|
|
29
|
+
approvals: Iterable[Approval] = (),
|
|
30
|
+
) -> Decision:
|
|
31
|
+
approvals = list(approvals)
|
|
32
|
+
decision = Decision()
|
|
33
|
+
|
|
34
|
+
def authorized_for(lock: Lock) -> set:
|
|
35
|
+
return set(lock.owners) | set(config.leaders)
|
|
36
|
+
|
|
37
|
+
def check(lock_id: str, lock: Optional[Lock], path: str, what: str, old, new) -> None:
|
|
38
|
+
owners_and_leaders = authorized_for(lock) if lock else set(config.leaders)
|
|
39
|
+
if actor in owners_and_leaders:
|
|
40
|
+
return
|
|
41
|
+
approvers = owners_and_leaders | (set(lock.approvers) if lock else set())
|
|
42
|
+
digest = change_digest(lock_id, path, old, new)
|
|
43
|
+
for a in approvals:
|
|
44
|
+
if (
|
|
45
|
+
a.lock_id == lock_id
|
|
46
|
+
and a.digest == digest
|
|
47
|
+
and a.approver != actor
|
|
48
|
+
and a.approver in approvers
|
|
49
|
+
):
|
|
50
|
+
return
|
|
51
|
+
ask = ", ".join(sorted(approvers)) or "(nobody configured)"
|
|
52
|
+
decision.violations.append(
|
|
53
|
+
Violation(lock_id, path, "%s '%s' in %s is locked; ask: %s" % (what, lock_id, path, ask), digest)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
for ch in changes:
|
|
57
|
+
old, new = _norm(ch.old), _norm(ch.new)
|
|
58
|
+
|
|
59
|
+
if ch.path == RULES_PATH:
|
|
60
|
+
if old != new and actor not in config.leaders:
|
|
61
|
+
decision.violations.append(
|
|
62
|
+
Violation("<rules>", ch.path, "the rules file may only be changed by leaders: " + ", ".join(sorted(config.leaders)))
|
|
63
|
+
)
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
for lock in config.locks:
|
|
67
|
+
if lock.matches_path(ch.path) and old != new:
|
|
68
|
+
check(lock.id, lock, ch.path, "path lock", old, new)
|
|
69
|
+
|
|
70
|
+
old_blocks = extract_blocks(ch.old) if ch.old is not None else {}
|
|
71
|
+
new_blocks = extract_blocks(ch.new) if ch.new is not None else {}
|
|
72
|
+
for block_id in sorted(set(old_blocks) | set(new_blocks)):
|
|
73
|
+
o, n = old_blocks.get(block_id), new_blocks.get(block_id)
|
|
74
|
+
if o != n:
|
|
75
|
+
check(block_id, config.get(block_id), ch.path, "block", o, n)
|
|
76
|
+
|
|
77
|
+
return decision
|
tripwire/gitea.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Gitea / Forgejo adapter: approvals come from pull-request reviews.
|
|
2
|
+
|
|
3
|
+
A review counts when it is the reviewer's latest decisive review, its state is
|
|
4
|
+
APPROVED, it is neither stale nor dismissed, and it was made on the PR's
|
|
5
|
+
current head commit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Dict, Iterable, List, Optional, Set
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from .config import ConfigError
|
|
15
|
+
from .httpjson import Fetch, get_all, urlopen_fetch
|
|
16
|
+
from .models import Decision
|
|
17
|
+
from .review import check_review
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GiteaClient:
|
|
21
|
+
def __init__(self, repo: str, api_url: str, token: Optional[str] = None, fetch: Optional[Fetch] = None):
|
|
22
|
+
self.repo, self.api_url = repo, api_url.rstrip("/")
|
|
23
|
+
headers = {"Accept": "application/json"}
|
|
24
|
+
if token:
|
|
25
|
+
headers["Authorization"] = "token " + token
|
|
26
|
+
self._fetch = fetch or urlopen_fetch(headers)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_env(cls, repo: Optional[str] = None, api_url: Optional[str] = None) -> "GiteaClient":
|
|
30
|
+
repo = repo or os.environ.get("GITEA_REPOSITORY") or os.environ.get("GITHUB_REPOSITORY")
|
|
31
|
+
if not repo:
|
|
32
|
+
raise ConfigError("repository unknown; pass --gitea-repo or set GITHUB_REPOSITORY")
|
|
33
|
+
api_url = api_url or os.environ.get("GITEA_API_URL") or os.environ.get("GITHUB_API_URL")
|
|
34
|
+
if not api_url and os.environ.get("GITHUB_SERVER_URL"):
|
|
35
|
+
api_url = os.environ["GITHUB_SERVER_URL"].rstrip("/") + "/api/v1"
|
|
36
|
+
if not api_url:
|
|
37
|
+
raise ConfigError("server unknown; pass --gitea-url (e.g. https://git.example.com/api/v1)")
|
|
38
|
+
return cls(repo, api_url, os.environ.get("GITEA_TOKEN") or os.environ.get("GITHUB_TOKEN"))
|
|
39
|
+
|
|
40
|
+
def _all(self, path: str, query: str = "") -> List[dict]:
|
|
41
|
+
return get_all(self._fetch, "%s/repos/%s/%s%s" % (self.api_url, self.repo, path, query))
|
|
42
|
+
|
|
43
|
+
def get_pull(self, number: int) -> dict:
|
|
44
|
+
return self._all("pulls/%d" % number)[0]
|
|
45
|
+
|
|
46
|
+
def get_reviews(self, number: int) -> List[dict]:
|
|
47
|
+
return self._all("pulls/%d/reviews" % number, "?limit=50")
|
|
48
|
+
|
|
49
|
+
def get_branch_sha(self, branch: str) -> str:
|
|
50
|
+
return self._all("branches/" + quote(branch, safe="/"))[0]["commit"]["id"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def approved_logins(reviews: Iterable[dict], head_sha: str) -> Set[str]:
|
|
54
|
+
latest: Dict[str, dict] = {}
|
|
55
|
+
for review in reviews: # oldest first
|
|
56
|
+
user = (review.get("user") or {}).get("login")
|
|
57
|
+
if user and review.get("state") in ("APPROVED", "REQUEST_CHANGES"):
|
|
58
|
+
latest[user] = review
|
|
59
|
+
return {
|
|
60
|
+
u
|
|
61
|
+
for u, r in latest.items()
|
|
62
|
+
if r["state"] == "APPROVED" and not r.get("stale") and not r.get("dismissed") and r.get("commit_id") == head_sha
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_pull_request(root: str, client: GiteaClient, number: int) -> Decision:
|
|
67
|
+
pr = client.get_pull(number)
|
|
68
|
+
head_sha = pr["head"]["sha"]
|
|
69
|
+
approvers = approved_logins(client.get_reviews(number), head_sha)
|
|
70
|
+
target_sha = client.get_branch_sha(pr["base"]["ref"])
|
|
71
|
+
return check_review(root, target_sha, head_sha, pr["user"]["login"], approvers)
|
tripwire/github.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""GitHub adapter: approvals come from pull-request reviews.
|
|
2
|
+
|
|
3
|
+
A review only counts when it is the reviewer's latest review, its state is
|
|
4
|
+
APPROVED, and it was made on the PR's current head commit. Pushing new commits
|
|
5
|
+
therefore voids earlier approvals (the head sha changes).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Dict, Iterable, List, Optional, Set
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from .config import ConfigError
|
|
15
|
+
from .httpjson import Fetch, get_all, urlopen_fetch
|
|
16
|
+
from .models import Decision
|
|
17
|
+
from .review import check_review
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitHubClient:
|
|
21
|
+
def __init__(self, repo: str, token: Optional[str] = None, api_url: str = "https://api.github.com", fetch: Optional[Fetch] = None):
|
|
22
|
+
self.repo, self.api_url = repo, api_url.rstrip("/")
|
|
23
|
+
headers = {"Accept": "application/vnd.github+json"}
|
|
24
|
+
if token:
|
|
25
|
+
headers["Authorization"] = "Bearer " + token
|
|
26
|
+
self._fetch = fetch or urlopen_fetch(headers)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_env(cls, repo: Optional[str] = None) -> "GitHubClient":
|
|
30
|
+
repo = repo or os.environ.get("GITHUB_REPOSITORY")
|
|
31
|
+
if not repo:
|
|
32
|
+
raise ConfigError("repository unknown; pass --github-repo or set GITHUB_REPOSITORY")
|
|
33
|
+
return cls(repo, os.environ.get("GITHUB_TOKEN"), os.environ.get("GITHUB_API_URL", "https://api.github.com"))
|
|
34
|
+
|
|
35
|
+
def _all(self, path: str) -> List[dict]:
|
|
36
|
+
return get_all(self._fetch, "%s/repos/%s/%s?per_page=100" % (self.api_url, self.repo, path))
|
|
37
|
+
|
|
38
|
+
def get_pull(self, number: int) -> dict:
|
|
39
|
+
return self._all("pulls/%d" % number)[0]
|
|
40
|
+
|
|
41
|
+
def get_reviews(self, number: int) -> List[dict]:
|
|
42
|
+
return self._all("pulls/%d/reviews" % number)
|
|
43
|
+
|
|
44
|
+
def get_branch_sha(self, branch: str) -> str:
|
|
45
|
+
return self._all("branches/" + quote(branch, safe="/"))[0]["commit"]["sha"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def approved_logins(reviews: Iterable[dict], head_sha: str) -> Set[str]:
|
|
49
|
+
"""Logins whose latest decisive review is an approval of `head_sha`."""
|
|
50
|
+
latest: Dict[str, dict] = {}
|
|
51
|
+
for review in reviews: # the API returns reviews oldest-first
|
|
52
|
+
user = (review.get("user") or {}).get("login")
|
|
53
|
+
if user and review.get("state") in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED"):
|
|
54
|
+
latest[user] = review
|
|
55
|
+
return {u for u, r in latest.items() if r["state"] == "APPROVED" and r.get("commit_id") == head_sha}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_pull_request(root: str, client: GitHubClient, number: int) -> Decision:
|
|
59
|
+
pr = client.get_pull(number)
|
|
60
|
+
head_sha = pr["head"]["sha"]
|
|
61
|
+
approvers = approved_logins(client.get_reviews(number), head_sha)
|
|
62
|
+
# pr["base"]["sha"] is stale once the target branch moves; use its current tip for the rules.
|
|
63
|
+
target_sha = client.get_branch_sha(pr["base"]["ref"])
|
|
64
|
+
return check_review(root, target_sha, head_sha, pr["user"]["login"], approvers)
|
tripwire/gitio.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from .models import FileChange
|
|
8
|
+
|
|
9
|
+
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
10
|
+
ZERO = "0" * 40
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def git(cwd: str, *args: str, check: bool = True) -> bytes:
|
|
18
|
+
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True)
|
|
19
|
+
if check and proc.returncode != 0:
|
|
20
|
+
raise GitError("git %s failed: %s" % (" ".join(args), proc.stderr.decode("utf-8", "replace").strip()))
|
|
21
|
+
return proc.stdout if proc.returncode == 0 else b""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def toplevel(cwd: str) -> str:
|
|
25
|
+
return git(cwd, "rev-parse", "--show-toplevel").decode().strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RevSource:
|
|
29
|
+
def __init__(self, cwd: str, rev: str):
|
|
30
|
+
self.cwd, self.rev = cwd, rev
|
|
31
|
+
|
|
32
|
+
def read(self, path: str) -> Optional[str]:
|
|
33
|
+
proc = subprocess.run(["git", "show", "%s:%s" % (self.rev, path)], cwd=self.cwd, capture_output=True)
|
|
34
|
+
return proc.stdout.decode("utf-8", "replace") if proc.returncode == 0 else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WorktreeSource:
|
|
38
|
+
def __init__(self, root: str):
|
|
39
|
+
self.root = root
|
|
40
|
+
|
|
41
|
+
def read(self, path: str) -> Optional[str]:
|
|
42
|
+
full = os.path.join(self.root, path)
|
|
43
|
+
if not os.path.isfile(full):
|
|
44
|
+
return None
|
|
45
|
+
with open(full, "rb") as fh:
|
|
46
|
+
return fh.read().decode("utf-8", "replace")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _split(raw: bytes) -> List[str]:
|
|
50
|
+
return [p.decode("utf-8") for p in raw.split(b"\0") if p]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def changed_paths(cwd: str, base: str, head: Optional[str]) -> List[str]:
|
|
54
|
+
"""head=None is the working tree, head="" is the index (staged changes)."""
|
|
55
|
+
if head == "":
|
|
56
|
+
return _split(git(cwd, "diff", "--cached", "--name-only", "-z", "--no-renames", base))
|
|
57
|
+
if head is not None:
|
|
58
|
+
return _split(git(cwd, "diff", "--name-only", "-z", "--no-renames", base, head))
|
|
59
|
+
tracked = _split(git(cwd, "diff", "--name-only", "-z", "--no-renames", base))
|
|
60
|
+
untracked = _split(git(cwd, "ls-files", "--others", "--exclude-standard", "-z"))
|
|
61
|
+
return sorted(set(tracked) | set(untracked))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def collect_changes(cwd: str, base: str, head: Optional[str]) -> List[FileChange]:
|
|
65
|
+
old_src = RevSource(cwd, base)
|
|
66
|
+
new_src = RevSource(cwd, head) if head is not None else WorktreeSource(cwd)
|
|
67
|
+
return [FileChange(p, old_src.read(p), new_src.read(p)) for p in changed_paths(cwd, base, head)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def new_branch_base(cwd: str, new: str) -> Optional[str]:
|
|
71
|
+
"""Base for a newly created branch: parent of its first not-yet-known commit."""
|
|
72
|
+
out = git(cwd, "rev-list", "--reverse", new, "--not", "--all").decode().split()
|
|
73
|
+
if not out:
|
|
74
|
+
return None
|
|
75
|
+
proc = subprocess.run(["git", "rev-parse", out[0] + "^"], cwd=cwd, capture_output=True)
|
|
76
|
+
return proc.stdout.decode().strip() if proc.returncode == 0 else EMPTY_TREE
|
tripwire/gitlab.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""GitLab adapter: approvals come from merge-request approvals.
|
|
2
|
+
|
|
3
|
+
The approvals endpoint is not tied to a commit, so stale approvals are only
|
|
4
|
+
voided if the project setting "Remove all approvals when commits are added to
|
|
5
|
+
the source branch" is enabled. Turn it on; tripwire cannot verify it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Iterable, List, Optional, Set
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from .config import ConfigError
|
|
15
|
+
from .httpjson import Fetch, get_all, urlopen_fetch
|
|
16
|
+
from .models import Decision
|
|
17
|
+
from .review import check_review
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitLabClient:
|
|
21
|
+
def __init__(self, project: str, token: Optional[str] = None, api_url: str = "https://gitlab.com/api/v4",
|
|
22
|
+
job_token: bool = False, fetch: Optional[Fetch] = None):
|
|
23
|
+
self.project, self.api_url = quote(str(project), safe=""), api_url.rstrip("/")
|
|
24
|
+
headers = {}
|
|
25
|
+
if token:
|
|
26
|
+
headers["JOB-TOKEN" if job_token else "PRIVATE-TOKEN"] = token
|
|
27
|
+
self._fetch = fetch or urlopen_fetch(headers)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_env(cls, project: Optional[str] = None) -> "GitLabClient":
|
|
31
|
+
project = project or os.environ.get("CI_PROJECT_ID")
|
|
32
|
+
if not project:
|
|
33
|
+
raise ConfigError("project unknown; pass --gitlab-project or set CI_PROJECT_ID")
|
|
34
|
+
api_url = os.environ.get("CI_API_V4_URL", "https://gitlab.com/api/v4")
|
|
35
|
+
token = os.environ.get("GITLAB_TOKEN")
|
|
36
|
+
if token:
|
|
37
|
+
return cls(project, token, api_url)
|
|
38
|
+
return cls(project, os.environ.get("CI_JOB_TOKEN"), api_url, job_token=True)
|
|
39
|
+
|
|
40
|
+
def _all(self, path: str) -> List[dict]:
|
|
41
|
+
return get_all(self._fetch, "%s/projects/%s/merge_requests/%s" % (self.api_url, self.project, path))
|
|
42
|
+
|
|
43
|
+
def get_merge_request(self, iid: int) -> dict:
|
|
44
|
+
return self._all("%d" % iid)[0]
|
|
45
|
+
|
|
46
|
+
def get_approvals(self, iid: int) -> dict:
|
|
47
|
+
return self._all("%d/approvals" % iid)[0]
|
|
48
|
+
|
|
49
|
+
def get_branch_sha(self, branch: str) -> str:
|
|
50
|
+
url = "%s/projects/%s/repository/branches/%s" % (self.api_url, self.project, quote(branch, safe=""))
|
|
51
|
+
return get_all(self._fetch, url)[0]["commit"]["id"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def approved_usernames(approvals: dict) -> Set[str]:
|
|
55
|
+
return {(a.get("user") or {}).get("username") for a in approvals.get("approved_by", [])} - {None}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_merge_request(root: str, client: GitLabClient, iid: int) -> Decision:
|
|
59
|
+
mr = client.get_merge_request(iid)
|
|
60
|
+
refs = mr.get("diff_refs") or {}
|
|
61
|
+
head_sha = mr.get("sha") or refs.get("head_sha")
|
|
62
|
+
if not head_sha:
|
|
63
|
+
raise ConfigError("merge request has no diff refs yet; try again once GitLab has computed the diff")
|
|
64
|
+
# diff_refs.start_sha is stale once the target branch moves; use its current tip for the rules.
|
|
65
|
+
target_sha = client.get_branch_sha(mr["target_branch"])
|
|
66
|
+
return check_review(root, target_sha, head_sha, mr["author"]["username"], approved_usernames(client.get_approvals(iid)))
|
tripwire/httpjson.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
Fetch = Callable[[str], Tuple[bytes, str]] # url -> (body, Link header)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def urlopen_fetch(headers: Dict[str, str]) -> Fetch:
|
|
11
|
+
def fetch(url: str) -> Tuple[bytes, str]:
|
|
12
|
+
req = urllib.request.Request(url, headers=headers)
|
|
13
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
14
|
+
return resp.read(), resp.headers.get("Link", "")
|
|
15
|
+
|
|
16
|
+
return fetch
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def next_link(link: str) -> Optional[str]:
|
|
20
|
+
for part in link.split(","):
|
|
21
|
+
if 'rel="next"' in part and "<" in part:
|
|
22
|
+
return part[part.index("<") + 1 : part.index(">")]
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_all(fetch: Fetch, url: Optional[str]) -> List[dict]:
|
|
27
|
+
"""GET a URL and follow Link rel=next; a single object becomes a 1-item list."""
|
|
28
|
+
items: List[dict] = []
|
|
29
|
+
while url:
|
|
30
|
+
body, link = fetch(url)
|
|
31
|
+
data = json.loads(body)
|
|
32
|
+
items.extend(data if isinstance(data, list) else [data])
|
|
33
|
+
url = next_link(link)
|
|
34
|
+
return items
|
tripwire/models.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class FileChange:
|
|
9
|
+
path: str
|
|
10
|
+
old: Optional[str] # None: file did not exist before
|
|
11
|
+
new: Optional[str] # None: file no longer exists
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Approval:
|
|
16
|
+
lock_id: str
|
|
17
|
+
approver: str
|
|
18
|
+
digest: str # binds the approval to the exact before/after content
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Violation:
|
|
23
|
+
lock_id: str
|
|
24
|
+
path: str
|
|
25
|
+
message: str
|
|
26
|
+
digest: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Decision:
|
|
31
|
+
violations: List[Violation] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def allowed(self) -> bool:
|
|
35
|
+
return not self.violations
|
tripwire/review.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Platform-neutral review check shared by the GitHub and GitLab adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Iterable
|
|
6
|
+
|
|
7
|
+
from .config import RULES_PATH, parse_config
|
|
8
|
+
from .engine import evaluate
|
|
9
|
+
from .gitio import RevSource, collect_changes, git
|
|
10
|
+
from .models import Approval, Decision
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_review(root: str, target_sha: str, head_sha: str, author: str, approvers: Iterable[str]) -> Decision:
|
|
14
|
+
"""Evaluate a pull/merge request.
|
|
15
|
+
|
|
16
|
+
Rules come from the target branch tip (`target_sha`), the diff from the
|
|
17
|
+
merge base to `head_sha`. `approvers` are platform user names whose approval
|
|
18
|
+
applies to the current head; names are matched to the rules case-insensitively.
|
|
19
|
+
"""
|
|
20
|
+
rules = RevSource(root, target_sha).read(RULES_PATH)
|
|
21
|
+
if rules is None:
|
|
22
|
+
return Decision()
|
|
23
|
+
config = parse_config(rules)
|
|
24
|
+
names: Dict[str, str] = {name.lower(): name for name in config.everyone()}
|
|
25
|
+
actor = names.get(author.lower(), author)
|
|
26
|
+
|
|
27
|
+
merge_base = git(root, "merge-base", target_sha, head_sha).decode().strip()
|
|
28
|
+
changes = collect_changes(root, merge_base, head_sha)
|
|
29
|
+
|
|
30
|
+
first = evaluate(config, changes, actor, [])
|
|
31
|
+
reviewers = sorted({names[u.lower()] for u in approvers if u.lower() in names})
|
|
32
|
+
approvals = [Approval(v.lock_id, who, v.digest) for v in first.violations if v.digest for who in reviewers]
|
|
33
|
+
return evaluate(config, changes, actor, approvals)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tripwire-git
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Protect blocks of code in Git: changes need approval from an owner, an approver or a leader.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: git,code-owners,approval,pre-receive,ci
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# tripwire
|
|
21
|
+
|
|
22
|
+
Protect blocks of code in Git. A protected block can only be changed by its
|
|
23
|
+
owner or a leader, or by someone whose change an authorized person approved.
|
|
24
|
+
Works from a local pre-commit check, a self-hosted `pre-receive` hook, or as a
|
|
25
|
+
required check on GitHub, GitLab and Gitea / Forgejo.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
1. Mark a block in any language, inside a comment:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
# @lock-begin billing
|
|
33
|
+
RATE = 5
|
|
34
|
+
# @lock-end billing
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
2. Create `.tripwire/locks.json` (see `locks.example.json`):
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"leaders": ["alice"],
|
|
42
|
+
"locks": [
|
|
43
|
+
{"id": "billing", "owners": ["bob"], "approvers": ["carol"]},
|
|
44
|
+
{"id": "prod-config", "owners": ["bob"], "paths": ["config/prod/", "*.pem"]}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
3. Check a change:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install tripwire-git
|
|
53
|
+
tripwire preflight --actor dave # uncommitted changes vs HEAD
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Output: `tripwire: BLOCKED` with `block 'billing' in app.py is locked; ask:
|
|
57
|
+
alice, bob, carol`, exit code 1.
|
|
58
|
+
|
|
59
|
+
## Concepts
|
|
60
|
+
|
|
61
|
+
- Blocks are marked with `@lock-begin <id>` / `@lock-end <id>`; files and
|
|
62
|
+
folders are locked with path patterns (`dir/` prefix or glob).
|
|
63
|
+
- Owners and leaders may edit freely. Anyone else needs an approval from an
|
|
64
|
+
owner, an approver or a leader. You cannot approve your own change.
|
|
65
|
+
- Approvals are bound to the exact before/after content of the protected
|
|
66
|
+
block (a SHA-256 digest). Edit the code again and the approval is void.
|
|
67
|
+
- Rules are always read from the **base** revision (the target branch), so a
|
|
68
|
+
change cannot rewrite the rules that judge it. Changing the rules file is
|
|
69
|
+
leaders-only and cannot be approved.
|
|
70
|
+
- Whitespace-only edits are ignored. Deleting markers counts as a change.
|
|
71
|
+
Unclosed blocks extend to end of file. A marker whose id is missing from the
|
|
72
|
+
rules is leaders-only.
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
tripwire preflight --actor alice # uncommitted changes vs HEAD
|
|
78
|
+
tripwire check --base origin/main --head HEAD --actor alice # a commit range
|
|
79
|
+
tripwire approve --base origin/main --head HEAD --approver carol # local approvals file
|
|
80
|
+
tripwire preflight --staged # staged changes only
|
|
81
|
+
tripwire install-hook # local pre-commit hook
|
|
82
|
+
tripwire pre-receive # server hook, reads stdin
|
|
83
|
+
tripwire github-check | gitlab-check | gitea-check # pull / merge requests
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Exit codes: 0 allowed, 1 blocked, 2 error (fails closed).
|
|
87
|
+
|
|
88
|
+
## Local early warning
|
|
89
|
+
|
|
90
|
+
`tripwire install-hook` installs a `pre-commit` hook that checks staged changes
|
|
91
|
+
and refuses the commit when a protected block is touched without permission
|
|
92
|
+
(or use `.pre-commit-hooks.yaml` with the pre-commit framework). Your Git
|
|
93
|
+
identity (`git config user.email`, or `TRIPWIRE_ACTOR`) must match a name in
|
|
94
|
+
`locks.json`. This is a convenience: `git commit --no-verify` skips it, so the
|
|
95
|
+
server-side or CI check below remains the real gate. Nothing can stop someone
|
|
96
|
+
from editing files in their own clone; tripwire stops unapproved changes from
|
|
97
|
+
landing on protected branches.
|
|
98
|
+
|
|
99
|
+
## Platforms
|
|
100
|
+
|
|
101
|
+
| Platform | Enforce with | Approvals from | Verified |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| Self-hosted Git | `hooks/pre-receive` | `.tripwire/approvals.json` | real git repos |
|
|
104
|
+
| GitHub | required check, `templates/github-actions.yml` | PR reviews | real GitHub |
|
|
105
|
+
| GitLab | required pipeline, `templates/gitlab-ci.yml` | MR approvals | simulated API only |
|
|
106
|
+
| Gitea / Forgejo | required check, `templates/gitea-actions.yml` | PR reviews | simulated API only |
|
|
107
|
+
|
|
108
|
+
On hosted services, also forbid direct pushes to the protected branch and add a
|
|
109
|
+
path lock for the CI configuration, otherwise a PR can delete the check.
|
|
110
|
+
|
|
111
|
+
**Self-hosted Git.** Copy `hooks/pre-receive` into the bare repo and set
|
|
112
|
+
`TRIPWIRE_ACTOR` from your server's authenticated user.
|
|
113
|
+
|
|
114
|
+
**GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
|
|
115
|
+
the PR's current head commit, so a new push voids it. Reviewer logins are
|
|
116
|
+
matched to names in `locks.json` case-insensitively; teams are not supported.
|
|
117
|
+
|
|
118
|
+
**GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
|
|
119
|
+
approvals endpoint is not bound to a commit, so enable "Remove all approvals
|
|
120
|
+
when commits are added to the source branch"; tripwire cannot verify it.
|
|
121
|
+
|
|
122
|
+
**Gitea / Forgejo.** Enable "Dismiss stale approvals". Stale and dismissed
|
|
123
|
+
reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
|
|
124
|
+
`GITHUB_API_URL` or `GITHUB_SERVER_URL`.
|
|
125
|
+
|
|
126
|
+
## Architecture
|
|
127
|
+
|
|
128
|
+
- `engine.py`: platform-neutral core (diff + actor + rules + approvals -> decision)
|
|
129
|
+
- `review.py`: shared pull / merge request check used by the platform adapters
|
|
130
|
+
- `github.py`, `gitlab.py`, `gitea.py`: API clients and approval mapping
|
|
131
|
+
- `approvals.py`: local approvals file
|
|
132
|
+
- `gitio.py`, `cli.py`: Git access and command line
|
|
133
|
+
|
|
134
|
+
## Limits
|
|
135
|
+
|
|
136
|
+
- Identity comes from `--actor` / `TRIPWIRE_ACTOR` locally; it is only as
|
|
137
|
+
trustworthy as whatever sets it. On hosted platforms it is the PR author.
|
|
138
|
+
- The local approvals file is trust-based: anyone able to commit could write an
|
|
139
|
+
entry naming an approver. Use platform approvals for real deployments.
|
|
140
|
+
- On every platform, people who can edit branch protection or CI settings are
|
|
141
|
+
trusted; tripwire cannot stop an admin from switching the rule off.
|
|
142
|
+
- Pushes with several authors are checked as one cumulative diff by the pusher.
|
|
143
|
+
- Whitespace-insensitive comparison also ignores indentation changes.
|
|
144
|
+
- Not yet: platform teams, Bitbucket, Azure DevOps, Gerrit, CODEOWNERS import.
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pip install -e .[dev]
|
|
150
|
+
pytest
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
tripwire/__init__.py,sha256=Tspa12_SJOhu1FozTxrEaul0g-Svx_SrnbgZf1Y7Bm8,92
|
|
2
|
+
tripwire/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
tripwire/approvals.py,sha256=4J5-zM9E_CDPluCPCEUJRgw8ya2ULPxI8KMmhEE1Qjk,894
|
|
4
|
+
tripwire/blocks.py,sha256=q1upIMSmoQKzrDWe4OFxt4mdhqxHkEzsfwmlgJSyDQ0,1299
|
|
5
|
+
tripwire/cli.py,sha256=hLFCc2nQ34Qv4_QXZv1SxcutrLVHHKEF-DHvclbHDXQ,9433
|
|
6
|
+
tripwire/config.py,sha256=kzRjuKPWs3Vb3rde8sk73PXThTtJu1Mlnbx4HVopyBw,2339
|
|
7
|
+
tripwire/engine.py,sha256=CgKqt2lMhj9an6gqeMDCKhkjaKsj_NmzO6_5HYoO1r8,2832
|
|
8
|
+
tripwire/gitea.py,sha256=y_f_VOgcZwb6GM-Q0G-IS_YIF9qFQPSMKYCjgcruzds,3065
|
|
9
|
+
tripwire/github.py,sha256=XAy3SsjVlIKEv8MywgPs-Z5w3mhM4Tg2HPddxlZ-IHw,2828
|
|
10
|
+
tripwire/gitio.py,sha256=20b9mTeh4qgW0GR9Kt1jTjAw5tfHOwj_uIP8v9ZKHmY,2802
|
|
11
|
+
tripwire/gitlab.py,sha256=x_a0HkteJ4_9jP_5_1xP627E2sSQ-8XoBQ-8V4C1T0Q,2921
|
|
12
|
+
tripwire/httpjson.py,sha256=HH0NXYqROcuUPoU4E0AzI9IYUAt5wJGg4l4OaiF-0-s,1074
|
|
13
|
+
tripwire/models.py,sha256=8FldPQdHMuDSxzyUjqbPnzCfGOXG5MzjtiCtsIaGtHw,714
|
|
14
|
+
tripwire/review.py,sha256=lvMtbt0iyaaJx-q7EBsOuQrBjzIKfhp8GiFSfa9dhS4,1419
|
|
15
|
+
tripwire_git-0.1.0.dist-info/licenses/LICENSE,sha256=CKxFnG2lCnx7ytEwNI9slqCiZcIyV3FLWTE8ufLtL0M,1078
|
|
16
|
+
tripwire_git-0.1.0.dist-info/METADATA,sha256=Aw1pkN3RCTlORl52LXi2AaRE9SfuFz4DCW7kqCR29go,6443
|
|
17
|
+
tripwire_git-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
18
|
+
tripwire_git-0.1.0.dist-info/entry_points.txt,sha256=BDrNZ8ucyqLLgBgUG5f7gF1GQZ-RW7MEGNdQrRLr-H8,47
|
|
19
|
+
tripwire_git-0.1.0.dist-info/top_level.txt,sha256=Te7fsf-kKieL2zXPlqn5J6WVkel0JEr5Heqg66N3FuM,9
|
|
20
|
+
tripwire_git-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 codelock contributors
|
|
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
|
+
tripwire
|