@misterhuydo/sentinel 1.4.68 → 1.4.70

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.
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Add confirmed=false/true confirmation step to merge_pr.
4
+ On confirmed=false (default): fetch PR details, show plan, ask for confirmation.
5
+ On confirmed=true: execute the merge.
6
+ """
7
+ import ast, sys
8
+
9
+ BOSS = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
10
+
11
+ with open(BOSS, 'r', encoding='utf-8') as f:
12
+ boss = f.read()
13
+
14
+ # ── 1. Update tool definition — add confirmed param ──────────────────────────
15
+
16
+ OLD_DEF = ''' "name": "merge_pr",
17
+ "description": (
18
+ "ADMIN ONLY. Merge an open Sentinel PR into the main branch. "
19
+ "Use when AUTO_PUBLISH=false and you are satisfied with the fix after review. "
20
+ "Handles rebase conflicts automatically if possible. "
21
+ "e.g. 'merge the fix for Whydah-TypeLib', 'sync fix abc123 to main', "
22
+ "'merge the open PR for elprint-sales-core-service'"
23
+ ),
24
+ "input_schema": {
25
+ "type": "object",
26
+ "properties": {
27
+ "repo_name": {
28
+ "type": "string",
29
+ "description": "Repository name (must match a repo in config/repos/)",
30
+ },
31
+ "fingerprint": {
32
+ "type": "string",
33
+ "description": "Optional 8-char fingerprint to target a specific fix PR. "
34
+ "If omitted, merges the most recent open PR for the repo.",
35
+ },
36
+ "pr_number": {
37
+ "type": "integer",
38
+ "description": "Merge a specific PR by number (e.g. a Renovate PR). "
39
+ "When set, repo_name is still required but fingerprint is ignored.",
40
+ },
41
+ },
42
+ "required": ["repo_name"],
43
+ },
44
+ },'''
45
+
46
+ NEW_DEF = ''' "name": "merge_pr",
47
+ "description": (
48
+ "ADMIN ONLY. Merge a PR into the main branch. "
49
+ "ALWAYS call with confirmed=false first to show PR details for admin review — "
50
+ "never merge without showing the plan first. "
51
+ "Use for Sentinel fix PRs (AUTO_PUBLISH=false) or Renovate/external PRs by number. "
52
+ "confirmed=false fetches and shows PR details; confirmed=true executes the merge. "
53
+ "e.g. 'merge the fix for Whydah-TypeLib', 'merge TypeLib PR #247'"
54
+ ),
55
+ "input_schema": {
56
+ "type": "object",
57
+ "properties": {
58
+ "repo_name": {
59
+ "type": "string",
60
+ "description": "Repository name (must match a repo in config/repos/)",
61
+ },
62
+ "fingerprint": {
63
+ "type": "string",
64
+ "description": "Optional 8-char fingerprint to target a specific Sentinel fix PR.",
65
+ },
66
+ "pr_number": {
67
+ "type": "integer",
68
+ "description": "Merge a specific PR by number (e.g. a Renovate PR). "
69
+ "When set, repo_name is still required but fingerprint is ignored.",
70
+ },
71
+ "confirmed": {
72
+ "type": "boolean",
73
+ "description": (
74
+ "false (default) = fetch PR details and show plan for review, do NOT merge yet. "
75
+ "true = execute the merge after admin has seen and approved the plan."
76
+ ),
77
+ },
78
+ },
79
+ "required": ["repo_name"],
80
+ },
81
+ },'''
82
+
83
+ if OLD_DEF not in boss:
84
+ print("ERROR: merge_pr tool definition not found")
85
+ sys.exit(1)
86
+ boss = boss.replace(OLD_DEF, NEW_DEF, 1)
87
+
88
+ # ── 2. Update handler — add plan phase before both paths ─────────────────────
89
+
90
+ OLD_HANDLER_START = ''' if name == "merge_pr":
91
+ import re as _re
92
+ import requests as _req
93
+ repo_name = inputs.get("repo_name", "").strip()
94
+ fingerprint = inputs.get("fingerprint", "").strip()
95
+ pr_number_in = inputs.get("pr_number") # explicit PR number (e.g. Renovate PR)
96
+ github_token = cfg_loader.sentinel.github_token
97
+ if not github_token:
98
+ return json.dumps({"error": "GITHUB_TOKEN not configured"})
99
+
100
+ headers = {
101
+ "Authorization": f"Bearer {github_token}",
102
+ "Accept": "application/vnd.github+json",
103
+ }
104
+
105
+ # Fuzzy-match repo name against known configs
106
+ if repo_name not in cfg_loader.repos:
107
+ for rname in cfg_loader.repos:
108
+ if repo_name.lower() in rname.lower():
109
+ repo_name = rname
110
+ break
111
+
112
+ # ── Path A: explicit pr_number — merge directly via GitHub API ────
113
+ if pr_number_in:'''
114
+
115
+ NEW_HANDLER_START = ''' if name == "merge_pr":
116
+ import re as _re
117
+ import requests as _req
118
+ repo_name = inputs.get("repo_name", "").strip()
119
+ fingerprint = inputs.get("fingerprint", "").strip()
120
+ pr_number_in = inputs.get("pr_number") # explicit PR number (e.g. Renovate PR)
121
+ confirmed = bool(inputs.get("confirmed", False))
122
+ github_token = cfg_loader.sentinel.github_token
123
+ if not github_token:
124
+ return json.dumps({"error": "GITHUB_TOKEN not configured"})
125
+
126
+ headers = {
127
+ "Authorization": f"Bearer {github_token}",
128
+ "Accept": "application/vnd.github+json",
129
+ }
130
+
131
+ # Fuzzy-match repo name against known configs
132
+ if repo_name not in cfg_loader.repos:
133
+ for rname in cfg_loader.repos:
134
+ if repo_name.lower() in rname.lower():
135
+ repo_name = rname
136
+ break
137
+
138
+ # ── Path A: explicit pr_number — fetch details, then merge ────────
139
+ if pr_number_in:'''
140
+
141
+ if OLD_HANDLER_START not in boss:
142
+ print("ERROR: merge_pr handler start not found")
143
+ sys.exit(1)
144
+ boss = boss.replace(OLD_HANDLER_START, NEW_HANDLER_START, 1)
145
+
146
+ # ── 3. After fetching PR details in Path A, add plan phase before merge ───────
147
+
148
+ OLD_PATH_A_MERGE = ''' pr_data = pr_resp.json()
149
+ if pr_data.get("state") != "open":
150
+ return json.dumps({"error": f"PR #{pr_number_in} is already {pr_data.get('state')} — nothing to merge"})
151
+ pr_url = pr_data.get("html_url", "")
152
+ branch = pr_data.get("head", {}).get("ref", "")
153
+ pr_title = pr_data.get("title", "")
154
+
155
+ merge_resp = _req.put('''
156
+
157
+ NEW_PATH_A_MERGE = ''' pr_data = pr_resp.json()
158
+ if pr_data.get("state") != "open":
159
+ return json.dumps({"error": f"PR #{pr_number_in} is already {pr_data.get('state')} — nothing to merge"})
160
+ pr_url = pr_data.get("html_url", "")
161
+ branch = pr_data.get("head", {}).get("ref", "")
162
+ pr_title = pr_data.get("title", "")
163
+ pr_author = pr_data.get("user", {}).get("login", "unknown")
164
+ pr_body = (pr_data.get("body") or "")[:300]
165
+ files_changed = pr_data.get("changed_files", "?")
166
+ additions = pr_data.get("additions", "?")
167
+ deletions = pr_data.get("deletions", "?")
168
+ mergeable = pr_data.get("mergeable")
169
+
170
+ # ── Plan phase — always show before merging ───────────────────
171
+ if not confirmed:
172
+ return json.dumps({
173
+ "plan": f"Merge PR #{pr_number_in} into {repo_name}",
174
+ "pr_number": pr_number_in,
175
+ "pr_url": pr_url,
176
+ "title": pr_title,
177
+ "author": pr_author,
178
+ "branch": branch,
179
+ "files_changed": files_changed,
180
+ "additions": additions,
181
+ "deletions": deletions,
182
+ "mergeable": mergeable,
183
+ "description": pr_body or "(no description)",
184
+ "confirm_prompt": (
185
+ f"This will squash-merge PR #{pr_number_in} (\\"{pr_title}\\") "
186
+ f"from {pr_author} into {repo_name}. Reply with confirmed=true to proceed."
187
+ ),
188
+ })
189
+
190
+ merge_resp = _req.put('''
191
+
192
+ if OLD_PATH_A_MERGE not in boss:
193
+ print("ERROR: Path A merge block not found")
194
+ sys.exit(1)
195
+ boss = boss.replace(OLD_PATH_A_MERGE, NEW_PATH_A_MERGE, 1)
196
+
197
+ # ── 4. Add plan phase for Path B (state_store PRs) ────────────────────────────
198
+
199
+ OLD_PATH_B = ''' # ── Path B: state_store lookup (Sentinel-managed PRs) ─────────────
200
+ open_prs = store.get_open_prs()
201
+ candidates = [p for p in open_prs if p.get("repo_name") == repo_name]
202
+ if fingerprint:
203
+ candidates = [p for p in candidates if p.get("fingerprint", "").startswith(fingerprint)]
204
+ if not candidates:
205
+ return json.dumps({"error": f"No open Sentinel PR found for repo \'{repo_name}\'"
206
+ + (f" with fingerprint \'{fingerprint}\'" if fingerprint else "")})
207
+ fix = candidates[0] # most recent
208
+ pr_url = fix.get("pr_url", "")
209
+ branch = fix.get("branch", "")
210
+ fp = fix.get("fingerprint", "")
211
+
212
+ # Parse owner/repo and PR number from pr_url
213
+ m = _re.search(r"github\\.com/([^/]+/[^/]+)/pull/(\\d+)", pr_url)
214
+ if not m:
215
+ return json.dumps({"error": f"Cannot parse PR URL: {pr_url}"})
216
+ owner_repo = m.group(1)
217
+ pr_number = m.group(2)'''
218
+
219
+ NEW_PATH_B = ''' # ── Path B: state_store lookup (Sentinel-managed PRs) ─────────────
220
+ open_prs = store.get_open_prs()
221
+ candidates = [p for p in open_prs if p.get("repo_name") == repo_name]
222
+ if fingerprint:
223
+ candidates = [p for p in candidates if p.get("fingerprint", "").startswith(fingerprint)]
224
+ if not candidates:
225
+ return json.dumps({"error": f"No open Sentinel PR found for repo \'{repo_name}\'"
226
+ + (f" with fingerprint \'{fingerprint}\'" if fingerprint else "")})
227
+ fix = candidates[0] # most recent
228
+ pr_url = fix.get("pr_url", "")
229
+ branch = fix.get("branch", "")
230
+ fp = fix.get("fingerprint", "")
231
+
232
+ # Parse owner/repo and PR number from pr_url
233
+ m = _re.search(r"github\\.com/([^/]+/[^/]+)/pull/(\\d+)", pr_url)
234
+ if not m:
235
+ return json.dumps({"error": f"Cannot parse PR URL: {pr_url}"})
236
+ owner_repo = m.group(1)
237
+ pr_number = m.group(2)
238
+
239
+ # Fetch live PR details for the plan
240
+ pr_resp2 = _req.get(
241
+ f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number}",
242
+ headers=headers, timeout=15,
243
+ )
244
+ pr_detail = pr_resp2.json() if pr_resp2.status_code == 200 else {}
245
+ pr_title2 = pr_detail.get("title", fix.get("fingerprint", ""))
246
+ pr_state2 = pr_detail.get("state", "unknown")
247
+ files_changed2 = pr_detail.get("changed_files", "?")
248
+ additions2 = pr_detail.get("additions", "?")
249
+ deletions2 = pr_detail.get("deletions", "?")
250
+ pr_body2 = (pr_detail.get("body") or "")[:300]
251
+
252
+ if pr_state2 != "open" and pr_state2 != "unknown":
253
+ return json.dumps({"error": f"PR #{pr_number} is already {pr_state2} — nothing to merge"})
254
+
255
+ # ── Plan phase — always show before merging ───────────────────────
256
+ if not confirmed:
257
+ return json.dumps({
258
+ "plan": f"Merge Sentinel fix PR #{pr_number} into {repo_name}",
259
+ "pr_number": pr_number,
260
+ "pr_url": pr_url,
261
+ "title": pr_title2,
262
+ "fingerprint": fp[:8],
263
+ "branch": branch,
264
+ "files_changed": files_changed2,
265
+ "additions": additions2,
266
+ "deletions": deletions2,
267
+ "description": pr_body2 or "(no description)",
268
+ "confirm_prompt": (
269
+ f"This will squash-merge Sentinel fix PR #{pr_number} (\\"{pr_title2}\\") "
270
+ f"into {repo_name}/{fix.get('branch', 'main')}. "
271
+ f"Reply with confirmed=true to proceed."
272
+ ),
273
+ })'''
274
+
275
+ if OLD_PATH_B not in boss:
276
+ print("ERROR: Path B block not found")
277
+ sys.exit(1)
278
+ boss = boss.replace(OLD_PATH_B, NEW_PATH_B, 1)
279
+
280
+ with open(BOSS, 'w', encoding='utf-8') as f:
281
+ f.write(boss)
282
+ print("Confirmation step added to merge_pr")
283
+
284
+ # ── Syntax check ──────────────────────────────────────────────────────────────
285
+ with open(BOSS, 'r', encoding='utf-8') as f:
286
+ src = f.read()
287
+ try:
288
+ ast.parse(src)
289
+ print("Syntax OK")
290
+ except SyntaxError as e:
291
+ lines = src.splitlines()
292
+ print(f"SyntaxError at line {e.lineno}: {e.msg}")
293
+ for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
294
+ print(f" {i+1}: {lines[i]}")
295
+ sys.exit(1)
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Make admin permission denial messages more descriptive — tell the user
4
+ exactly which operation they tried and how to get help.
5
+ """
6
+ import ast, sys
7
+
8
+ BOSS = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
9
+
10
+ with open(BOSS, 'r', encoding='utf-8') as f:
11
+ boss = f.read()
12
+
13
+ replacements = [
14
+ # list_prs
15
+ (
16
+ ' if name == "list_prs":\n if not is_admin:\n return json.dumps({"error": "Admin access required."})',
17
+ ' if name == "list_prs":\n if not is_admin:\n return json.dumps({"error": "list_prs is admin-only. Ask a Sentinel admin (SLACK_ADMIN_USERS) to run this for you."})',
18
+ ),
19
+ # drop_pr
20
+ (
21
+ ' if name == "drop_pr":\n if not is_admin:\n return json.dumps({"error": "Admin access required."})',
22
+ ' if name == "drop_pr":\n if not is_admin:\n return json.dumps({"error": "drop_pr is admin-only. Ask a Sentinel admin to drop this PR for you."})',
23
+ ),
24
+ # merge_pr confirmed=false check
25
+ (
26
+ ' if not is_admin:\n return json.dumps({"error": "Admin access required."})\n\n headers = {',
27
+ ' if not is_admin:\n return json.dumps({"error": "merge_pr is admin-only. Ask a Sentinel admin to merge this PR."})\n\n headers = {',
28
+ ),
29
+ # watch_bot
30
+ (
31
+ 'return json.dumps({"error": "Admin access required to register bots for monitoring."})',
32
+ 'return json.dumps({"error": "watch_bot is admin-only. Ask a Sentinel admin to register this bot."})',
33
+ ),
34
+ # unwatch_bot
35
+ (
36
+ 'return json.dumps({"error": "Admin access required to remove bots from monitoring."})',
37
+ 'return json.dumps({"error": "unwatch_bot is admin-only. Ask a Sentinel admin to remove this bot."})',
38
+ ),
39
+ # upgrade
40
+ (
41
+ 'return json.dumps({"error": "Admin access required to upgrade Sentinel."})',
42
+ 'return json.dumps({"error": "upgrade is admin-only. Ask a Sentinel admin to perform the upgrade."})',
43
+ ),
44
+ # restart_project
45
+ (
46
+ 'return json.dumps({"error": "Admin access required to restart a project."})',
47
+ 'return json.dumps({"error": "restart_project is admin-only. Ask a Sentinel admin to restart the project."})',
48
+ ),
49
+ # export_db (generic SLACK_ADMIN_USERS message)
50
+ (
51
+ 'return json.dumps({"error": "Admin access required. You are not in SLACK_ADMIN_USERS."})',
52
+ 'return json.dumps({"error": "This operation is admin-only (SLACK_ADMIN_USERS). Contact a Sentinel admin if you need access."})',
53
+ ),
54
+ ]
55
+
56
+ changed = 0
57
+ for old, new in replacements:
58
+ if old in boss:
59
+ boss = boss.replace(old, new, 1)
60
+ changed += 1
61
+ else:
62
+ print(f"WARNING: pattern not found — {old[:60]!r}")
63
+
64
+ print(f"Applied {changed}/{len(replacements)} replacements")
65
+
66
+ with open(BOSS, 'w', encoding='utf-8') as f:
67
+ f.write(boss)
68
+ print("Written OK")
69
+
70
+ try:
71
+ ast.parse(boss)
72
+ print("Syntax OK")
73
+ except SyntaxError as e:
74
+ lines = boss.splitlines()
75
+ print(f"SyntaxError at line {e.lineno}: {e.msg}")
76
+ for i in range(max(0, e.lineno-3), min(len(lines), e.lineno+3)):
77
+ print(f" {i+1}: {lines[i]}")
78
+ sys.exit(1)
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """Add HEAD-change detection to _pr_check_loop in main.py."""
3
+ import ast, sys
4
+
5
+ MAIN = '/home/sentinel/sentinel/code/sentinel/main.py'
6
+
7
+ with open(MAIN, 'r', encoding='utf-8') as f:
8
+ src = f.read()
9
+
10
+ OLD = ''' # Mark PRs in DB that are no longer open on GitHub
11
+ all_tracked = store.get_prs(repo_name=repo_name, state="open")
12
+ for tracked in all_tracked:
13
+ if tracked["pr_number"] not in seen_nums:
14
+ store.close_pr(repo_name, tracked["pr_number"], state="closed")
15
+ logger.info("PR check: closed PR %s #%d (no longer open on GitHub)",
16
+ repo_name, tracked["pr_number"])
17
+
18
+ except Exception as _e:
19
+ logger.debug("PR check error for %s: %s", repo_name, _e)
20
+ continue'''
21
+
22
+ NEW = ''' # Mark PRs in DB that are no longer open on GitHub
23
+ all_tracked = store.get_prs(repo_name=repo_name, state="open")
24
+ for tracked in all_tracked:
25
+ if tracked["pr_number"] not in seen_nums:
26
+ store.close_pr(repo_name, tracked["pr_number"], state="closed")
27
+ logger.info("PR check: closed PR %s #%d (no longer open on GitHub)",
28
+ repo_name, tracked["pr_number"])
29
+
30
+ # Detect HEAD change on default branch -> invalidate knowledge cache
31
+ try:
32
+ import subprocess as _sp2
33
+ _lp = getattr(repo_cfg, "local_path", None)
34
+ _branch = getattr(repo_cfg, "branch", "main") or "main"
35
+ if _lp:
36
+ _fetch = _sp2.run(
37
+ ["git", "fetch", "--quiet", "origin"],
38
+ cwd=str(_lp), capture_output=True, timeout=30,
39
+ )
40
+ if _fetch.returncode == 0:
41
+ _local = _sp2.run(
42
+ ["git", "rev-parse", "--short", "HEAD"],
43
+ cwd=str(_lp), capture_output=True, text=True, timeout=5,
44
+ ).stdout.strip()
45
+ _remote = _sp2.run(
46
+ ["git", "rev-parse", "--short", f"origin/{_branch}"],
47
+ cwd=str(_lp), capture_output=True, text=True, timeout=5,
48
+ ).stdout.strip()
49
+ if _local and _remote and _local != _remote:
50
+ _deleted = store.invalidate_knowledge(repo_name)
51
+ if _deleted:
52
+ logger.info(
53
+ "PR check: HEAD changed for %s (%s->%s), "
54
+ "invalidated %d cached knowledge entries",
55
+ repo_name, _local, _remote, _deleted,
56
+ )
57
+ except Exception as _ce:
58
+ logger.debug("HEAD-change check failed for %s: %s", repo_name, _ce)
59
+
60
+ except Exception as _e:
61
+ logger.debug("PR check error for %s: %s", repo_name, _e)
62
+ continue'''
63
+
64
+ if OLD not in src:
65
+ print("ERROR: close_pr block not found in main.py")
66
+ idx = src.find("no longer open on GitHub")
67
+ if idx >= 0:
68
+ print(repr(src[max(0,idx-100):idx+300]))
69
+ sys.exit(1)
70
+
71
+ src = src.replace(OLD, NEW, 1)
72
+ with open(MAIN, 'w', encoding='utf-8') as f:
73
+ f.write(src)
74
+ print("Step 5 OK: HEAD-change detection added to _pr_check_loop")
75
+
76
+ try:
77
+ ast.parse(src)
78
+ print("main.py Syntax OK")
79
+ except SyntaxError as e:
80
+ lines = src.splitlines()
81
+ print(f"SyntaxError line {e.lineno}: {e.msg}")
82
+ for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
83
+ print(f" {i+1}: {lines[i]}")
84
+ sys.exit(1)
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Fix broken f-string literal newlines in _pr_check_loop notification block."""
3
+ import ast, sys
4
+
5
+ MAIN = '/home/sentinel/sentinel/code/sentinel/main.py'
6
+ with open(MAIN, 'r', encoding='utf-8') as f:
7
+ src = f.read()
8
+
9
+ # The broken block — literal newlines inside f-strings
10
+ # We find it by unique surrounding context
11
+ if 'f":bell: *New PR*' not in src:
12
+ print("ERROR: broken block not found")
13
+ sys.exit(1)
14
+
15
+ # Find start and end of the broken msg = (...) block
16
+ start_marker = ' msg = (\n f":bell: *New PR*'
17
+ end_marker = ' f"or `drop PR #{pr[\'pr_number\']} in {pr[\'repo_name\']}`_"\n )'
18
+
19
+ start_idx = src.find(start_marker)
20
+ end_idx = src.find(end_marker)
21
+ if start_idx == -1 or end_idx == -1:
22
+ print(f"ERROR: markers not found (start={start_idx}, end={end_idx})")
23
+ # Show context around the bell
24
+ idx = src.find('"bell:')
25
+ print(repr(src[max(0,idx-200):idx+400]))
26
+ sys.exit(1)
27
+
28
+ end_idx += len(end_marker)
29
+ old_block = src[start_idx:end_idx]
30
+
31
+ new_block = (
32
+ ' _pr_parts = [\n'
33
+ ' f":bell: *New PR* \u2014 `{pr[\'repo_name\']}` #{pr[\'pr_number\']} [{source_tag}]",\n'
34
+ ' f"*{pr[\'title\']}*",\n'
35
+ ' f"By `{pr[\'author\']}` on branch `{pr[\'head_branch\']}` | {pr[\'pr_url\']}",\n'
36
+ ' ("_Reply:_ `list prs` to review, then"\n'
37
+ ' f" `merge PR #{pr[\'pr_number\']} in {pr[\'repo_name\']}` or"\n'
38
+ ' f" `drop PR #{pr[\'pr_number\']} in {pr[\'repo_name\']}`"),\n'
39
+ ' ]\n'
40
+ ' msg = "\\n".join(_pr_parts)'
41
+ )
42
+
43
+ src = src[:start_idx] + new_block + src[end_idx:]
44
+
45
+ with open(MAIN, 'w', encoding='utf-8') as f:
46
+ f.write(src)
47
+ print("Fixed: notification message block rewritten")
48
+
49
+ try:
50
+ ast.parse(src)
51
+ print("Syntax OK")
52
+ except SyntaxError as e:
53
+ lines = src.splitlines()
54
+ print(f"SyntaxError at line {e.lineno}: {e.msg}")
55
+ for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
56
+ print(f" {i+1}: {lines[i]}")
57
+ sys.exit(1)