@misterhuydo/sentinel 1.4.68 → 1.4.69
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.
- package/lib/.cairn/minify-map.json +12 -0
- package/lib/.cairn/views/244a09_generate.js +274 -0
- package/lib/.cairn/views/fb78ac_upgrade.js +16 -1
- package/lib/generate.js +6 -1
- package/package.json +1 -1
- package/python/scripts/fix_ask_codebase_context.py +249 -0
- package/python/scripts/fix_ask_codebase_stdin.py +49 -0
- package/python/scripts/fix_chain_slack.py +67 -0
- package/python/scripts/fix_fstring.py +51 -0
- package/python/scripts/fix_knowledge_cache.py +323 -0
- package/python/scripts/fix_knowledge_cache_staleness.py +294 -0
- package/python/scripts/fix_merge_confirm.py +295 -0
- package/python/scripts/fix_permission_messages.py +78 -0
- package/python/scripts/fix_pr_check_head_detect.py +84 -0
- package/python/scripts/fix_pr_msg_newlines.py +57 -0
- package/python/scripts/fix_pr_tracking_boss.py +265 -0
- package/python/scripts/fix_pr_tracking_db.py +212 -0
- package/python/scripts/fix_pr_tracking_main.py +174 -0
- package/python/scripts/fix_project_isolation.py +197 -0
- package/python/scripts/fix_system_prompt.py +444 -0
- package/python/scripts/fix_two_bugs.py +220 -0
- package/python/scripts/patch_chain_release.py +236 -0
- package/python/sentinel/cicd_trigger.py +125 -16
- package/python/sentinel/dependency_manager.py +129 -18
- package/python/sentinel/git_manager.py +46 -12
- package/python/sentinel/notify.py +34 -0
- package/python/sentinel/sentinel_boss.py +4139 -3326
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Make the knowledge cache commit-aware:
|
|
4
|
+
- Store the repo's HEAD commit SHA when caching an answer
|
|
5
|
+
- In get_knowledge, if a current_sha is passed and differs from stored sha → cache miss
|
|
6
|
+
- In ask_codebase handler, read git HEAD before checking cache
|
|
7
|
+
- Also: background refresh — _pr_check_loop detects HEAD changes and invalidates cache
|
|
8
|
+
"""
|
|
9
|
+
import ast, sys
|
|
10
|
+
|
|
11
|
+
STORE = '/home/sentinel/sentinel/code/sentinel/state_store.py'
|
|
12
|
+
BOSS = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
|
|
13
|
+
|
|
14
|
+
# ── 1. Add commit_hash column to knowledge_cache migration ────────────────────
|
|
15
|
+
|
|
16
|
+
with open(STORE, 'r', encoding='utf-8') as f:
|
|
17
|
+
store_src = f.read()
|
|
18
|
+
|
|
19
|
+
OLD_KC_TABLE = ''' """CREATE TABLE IF NOT EXISTS knowledge_cache (
|
|
20
|
+
cache_key TEXT PRIMARY KEY,
|
|
21
|
+
repo_name TEXT NOT NULL,
|
|
22
|
+
question TEXT NOT NULL,
|
|
23
|
+
answer TEXT NOT NULL,
|
|
24
|
+
source_tool TEXT DEFAULT 'ask_codebase',
|
|
25
|
+
cached_at TEXT NOT NULL,
|
|
26
|
+
expires_at TEXT NOT NULL,
|
|
27
|
+
hit_count INTEGER DEFAULT 0
|
|
28
|
+
)"""),'''
|
|
29
|
+
|
|
30
|
+
NEW_KC_TABLE = ''' """CREATE TABLE IF NOT EXISTS knowledge_cache (
|
|
31
|
+
cache_key TEXT PRIMARY KEY,
|
|
32
|
+
repo_name TEXT NOT NULL,
|
|
33
|
+
question TEXT NOT NULL,
|
|
34
|
+
answer TEXT NOT NULL,
|
|
35
|
+
source_tool TEXT DEFAULT 'ask_codebase',
|
|
36
|
+
cached_at TEXT NOT NULL,
|
|
37
|
+
expires_at TEXT NOT NULL,
|
|
38
|
+
hit_count INTEGER DEFAULT 0,
|
|
39
|
+
commit_hash TEXT DEFAULT ''
|
|
40
|
+
)"""),
|
|
41
|
+
("add_knowledge_commit_hash",
|
|
42
|
+
"ALTER TABLE knowledge_cache ADD COLUMN commit_hash TEXT DEFAULT ''"),'''
|
|
43
|
+
|
|
44
|
+
if OLD_KC_TABLE not in store_src:
|
|
45
|
+
print("ERROR: knowledge_cache table definition not found")
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
store_src = store_src.replace(OLD_KC_TABLE, NEW_KC_TABLE, 1)
|
|
48
|
+
print("Step 1 OK: commit_hash column added to knowledge_cache")
|
|
49
|
+
|
|
50
|
+
# ── 2. Update get_knowledge to accept and check current_sha ───────────────────
|
|
51
|
+
|
|
52
|
+
OLD_GET_KNOWLEDGE = ''' def get_knowledge(self, repo_name: str, question: str) -> str | None:
|
|
53
|
+
"""Return a cached answer if it exists and has not expired."""
|
|
54
|
+
key = self._knowledge_key(repo_name, question)
|
|
55
|
+
now = _now()
|
|
56
|
+
with self._conn() as conn:
|
|
57
|
+
row = conn.execute(
|
|
58
|
+
"SELECT answer, expires_at, hit_count FROM knowledge_cache "
|
|
59
|
+
"WHERE cache_key=? AND expires_at > ?",
|
|
60
|
+
(key, now),
|
|
61
|
+
).fetchone()
|
|
62
|
+
if row:
|
|
63
|
+
conn.execute(
|
|
64
|
+
"UPDATE knowledge_cache SET hit_count=? WHERE cache_key=?",
|
|
65
|
+
(row["hit_count"] + 1, key),
|
|
66
|
+
)
|
|
67
|
+
return row["answer"]
|
|
68
|
+
return None'''
|
|
69
|
+
|
|
70
|
+
NEW_GET_KNOWLEDGE = ''' def get_knowledge(self, repo_name: str, question: str,
|
|
71
|
+
current_sha: str = "") -> str | None:
|
|
72
|
+
"""Return a cached answer if it exists, not expired, and commit hash matches."""
|
|
73
|
+
key = self._knowledge_key(repo_name, question)
|
|
74
|
+
now = _now()
|
|
75
|
+
with self._conn() as conn:
|
|
76
|
+
row = conn.execute(
|
|
77
|
+
"SELECT answer, expires_at, hit_count, commit_hash FROM knowledge_cache "
|
|
78
|
+
"WHERE cache_key=? AND expires_at > ?",
|
|
79
|
+
(key, now),
|
|
80
|
+
).fetchone()
|
|
81
|
+
if not row:
|
|
82
|
+
return None
|
|
83
|
+
# If we know the current HEAD, verify cache was made from same commit
|
|
84
|
+
stored_sha = row["commit_hash"] or ""
|
|
85
|
+
if current_sha and stored_sha and current_sha != stored_sha:
|
|
86
|
+
logger.debug("Knowledge cache stale for %s (sha %s→%s)",
|
|
87
|
+
repo_name, stored_sha[:8], current_sha[:8])
|
|
88
|
+
conn.execute("DELETE FROM knowledge_cache WHERE cache_key=?", (key,))
|
|
89
|
+
return None # stale — fetch fresh
|
|
90
|
+
conn.execute(
|
|
91
|
+
"UPDATE knowledge_cache SET hit_count=? WHERE cache_key=?",
|
|
92
|
+
(row["hit_count"] + 1, key),
|
|
93
|
+
)
|
|
94
|
+
return row["answer"]'''
|
|
95
|
+
|
|
96
|
+
if OLD_GET_KNOWLEDGE not in store_src:
|
|
97
|
+
print("ERROR: get_knowledge method not found")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
store_src = store_src.replace(OLD_GET_KNOWLEDGE, NEW_GET_KNOWLEDGE, 1)
|
|
100
|
+
print("Step 2 OK: get_knowledge checks commit_hash")
|
|
101
|
+
|
|
102
|
+
# ── 3. Update save_knowledge to store commit_hash ─────────────────────────────
|
|
103
|
+
|
|
104
|
+
OLD_SAVE_KNOWLEDGE = ''' def save_knowledge(self, repo_name: str, question: str, answer: str,
|
|
105
|
+
ttl_hours: int = 24, source_tool: str = "ask_codebase") -> None:
|
|
106
|
+
"""Cache an answer. Overwrites any existing entry for this key."""
|
|
107
|
+
from datetime import timedelta
|
|
108
|
+
key = self._knowledge_key(repo_name, question)
|
|
109
|
+
now = _now()
|
|
110
|
+
expires = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat()
|
|
111
|
+
with self._conn() as conn:
|
|
112
|
+
conn.execute(
|
|
113
|
+
"INSERT OR REPLACE INTO knowledge_cache "
|
|
114
|
+
"(cache_key, repo_name, question, answer, source_tool, cached_at, expires_at, hit_count) "
|
|
115
|
+
"VALUES (?,?,?,?,?,?,?,0)",
|
|
116
|
+
(key, repo_name, question, answer, source_tool, now, expires),
|
|
117
|
+
)'''
|
|
118
|
+
|
|
119
|
+
NEW_SAVE_KNOWLEDGE = ''' def save_knowledge(self, repo_name: str, question: str, answer: str,
|
|
120
|
+
ttl_hours: int = 24, source_tool: str = "ask_codebase",
|
|
121
|
+
commit_hash: str = "") -> None:
|
|
122
|
+
"""Cache an answer, optionally tagging it with the repo's HEAD SHA."""
|
|
123
|
+
from datetime import timedelta
|
|
124
|
+
key = self._knowledge_key(repo_name, question)
|
|
125
|
+
now = _now()
|
|
126
|
+
expires = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat()
|
|
127
|
+
with self._conn() as conn:
|
|
128
|
+
conn.execute(
|
|
129
|
+
"INSERT OR REPLACE INTO knowledge_cache "
|
|
130
|
+
"(cache_key, repo_name, question, answer, source_tool, "
|
|
131
|
+
" cached_at, expires_at, hit_count, commit_hash) "
|
|
132
|
+
"VALUES (?,?,?,?,?,?,?,0,?)",
|
|
133
|
+
(key, repo_name, question, answer, source_tool, now, expires, commit_hash),
|
|
134
|
+
)'''
|
|
135
|
+
|
|
136
|
+
if OLD_SAVE_KNOWLEDGE not in store_src:
|
|
137
|
+
print("ERROR: save_knowledge method not found")
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
store_src = store_src.replace(OLD_SAVE_KNOWLEDGE, NEW_SAVE_KNOWLEDGE, 1)
|
|
140
|
+
print("Step 3 OK: save_knowledge stores commit_hash")
|
|
141
|
+
|
|
142
|
+
with open(STORE, 'w', encoding='utf-8') as f:
|
|
143
|
+
f.write(store_src)
|
|
144
|
+
try:
|
|
145
|
+
ast.parse(store_src)
|
|
146
|
+
print("state_store.py Syntax OK")
|
|
147
|
+
except SyntaxError as e:
|
|
148
|
+
lines = store_src.splitlines()
|
|
149
|
+
print(f"SyntaxError line {e.lineno}: {e.msg}")
|
|
150
|
+
for i in range(max(0, e.lineno-4), min(len(lines), e.lineno+3)):
|
|
151
|
+
print(f" {i+1}: {lines[i]}")
|
|
152
|
+
sys.exit(1)
|
|
153
|
+
|
|
154
|
+
# ── 4. Update ask_codebase to pass git HEAD to cache layer ────────────────────
|
|
155
|
+
|
|
156
|
+
with open(BOSS, 'r', encoding='utf-8') as f:
|
|
157
|
+
boss = f.read()
|
|
158
|
+
|
|
159
|
+
OLD_ASK_CACHED = ''' def _ask_cached(repo_name, repo_cfg) -> dict:
|
|
160
|
+
# Check knowledge cache first (skip for issues mode — always fresh)
|
|
161
|
+
if mode != "issues":
|
|
162
|
+
cached = store.get_knowledge(repo_name, question)
|
|
163
|
+
if cached:
|
|
164
|
+
logger.info("Boss ask_codebase %s: cache hit", repo_name)
|
|
165
|
+
return {"repo": repo_name, "answer": cached, "cached": True}
|
|
166
|
+
result = _ask_one(repo_name, repo_cfg)
|
|
167
|
+
# Save to cache if we got a useful answer
|
|
168
|
+
if result.get("answer") and len(result["answer"]) > 50 and mode != "issues":
|
|
169
|
+
store.save_knowledge(repo_name, question, result["answer"], ttl_hours=24)
|
|
170
|
+
return result'''
|
|
171
|
+
|
|
172
|
+
NEW_ASK_CACHED = ''' def _get_head_sha(repo_cfg) -> str:
|
|
173
|
+
"""Get the current HEAD commit SHA from the local clone (fast, no network)."""
|
|
174
|
+
try:
|
|
175
|
+
import subprocess as _sp
|
|
176
|
+
r = _sp.run(
|
|
177
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
178
|
+
cwd=str(Path(repo_cfg.local_path)),
|
|
179
|
+
capture_output=True, text=True, timeout=5,
|
|
180
|
+
)
|
|
181
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
182
|
+
except Exception:
|
|
183
|
+
return ""
|
|
184
|
+
|
|
185
|
+
def _ask_cached(repo_name, repo_cfg) -> dict:
|
|
186
|
+
# Check knowledge cache first (skip for issues mode — always fresh)
|
|
187
|
+
if mode != "issues":
|
|
188
|
+
current_sha = _get_head_sha(repo_cfg)
|
|
189
|
+
cached = store.get_knowledge(repo_name, question, current_sha=current_sha)
|
|
190
|
+
if cached:
|
|
191
|
+
logger.info("Boss ask_codebase %s: cache hit (sha=%s)", repo_name, current_sha[:8] if current_sha else "?")
|
|
192
|
+
return {"repo": repo_name, "answer": cached, "cached": True}
|
|
193
|
+
else:
|
|
194
|
+
current_sha = ""
|
|
195
|
+
result = _ask_one(repo_name, repo_cfg)
|
|
196
|
+
# Save to cache if we got a useful answer
|
|
197
|
+
if result.get("answer") and len(result["answer"]) > 50 and mode != "issues":
|
|
198
|
+
store.save_knowledge(repo_name, question, result["answer"],
|
|
199
|
+
ttl_hours=24, commit_hash=current_sha)
|
|
200
|
+
return result'''
|
|
201
|
+
|
|
202
|
+
if OLD_ASK_CACHED not in boss:
|
|
203
|
+
print("ERROR: _ask_cached block not found in boss")
|
|
204
|
+
sys.exit(1)
|
|
205
|
+
boss = boss.replace(OLD_ASK_CACHED, NEW_ASK_CACHED, 1)
|
|
206
|
+
print("Step 4 OK: ask_codebase passes HEAD SHA to cache")
|
|
207
|
+
|
|
208
|
+
# ── 5. Add HEAD-change detection to _pr_check_loop ────────────────────────────
|
|
209
|
+
|
|
210
|
+
with open(BOSS, 'w', encoding='utf-8') as f:
|
|
211
|
+
f.write(boss)
|
|
212
|
+
try:
|
|
213
|
+
ast.parse(boss)
|
|
214
|
+
print("sentinel_boss.py Syntax OK (steps 1-4)")
|
|
215
|
+
except SyntaxError as e:
|
|
216
|
+
lines = boss.splitlines()
|
|
217
|
+
print(f"SyntaxError line {e.lineno}: {e.msg}")
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
220
|
+
# ── 5 continues in main.py ────────────────────────────────────────────────────
|
|
221
|
+
MAIN = '/home/sentinel/sentinel/code/sentinel/main.py'
|
|
222
|
+
with open(MAIN, 'r', encoding='utf-8') as f:
|
|
223
|
+
boss = f.read()
|
|
224
|
+
|
|
225
|
+
OLD_CLOSE_PRS = ''' # Mark PRs in DB that are no longer open on GitHub
|
|
226
|
+
all_tracked = store.get_prs(repo_name=repo_name, state="open")
|
|
227
|
+
for tracked in all_tracked:
|
|
228
|
+
if tracked["pr_number"] not in seen_nums:
|
|
229
|
+
store.close_pr(repo_name, tracked["pr_number"], state="closed")
|
|
230
|
+
logger.info("PR check: closed PR %s #%d (no longer open on GitHub)",
|
|
231
|
+
repo_name, tracked["pr_number"])
|
|
232
|
+
|
|
233
|
+
except Exception as _e:
|
|
234
|
+
logger.debug("PR check error for %s: %s", repo_name, _e)
|
|
235
|
+
continue'''
|
|
236
|
+
|
|
237
|
+
NEW_CLOSE_PRS = ''' # Mark PRs in DB that are no longer open on GitHub
|
|
238
|
+
all_tracked = store.get_prs(repo_name=repo_name, state="open")
|
|
239
|
+
for tracked in all_tracked:
|
|
240
|
+
if tracked["pr_number"] not in seen_nums:
|
|
241
|
+
store.close_pr(repo_name, tracked["pr_number"], state="closed")
|
|
242
|
+
logger.info("PR check: closed PR %s #%d (no longer open on GitHub)",
|
|
243
|
+
repo_name, tracked["pr_number"])
|
|
244
|
+
|
|
245
|
+
# Detect HEAD change on default branch → invalidate knowledge cache
|
|
246
|
+
try:
|
|
247
|
+
import subprocess as _sp2
|
|
248
|
+
_lp = getattr(repo_cfg, "local_path", None)
|
|
249
|
+
if _lp:
|
|
250
|
+
_r = _sp2.run(
|
|
251
|
+
["git", "fetch", "--quiet", "origin"],
|
|
252
|
+
cwd=str(_lp), capture_output=True, timeout=30,
|
|
253
|
+
)
|
|
254
|
+
if _r.returncode == 0:
|
|
255
|
+
_local = _sp2.run(
|
|
256
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
257
|
+
cwd=str(_lp), capture_output=True, text=True, timeout=5,
|
|
258
|
+
).stdout.strip()
|
|
259
|
+
_remote = _sp2.run(
|
|
260
|
+
["git", "rev-parse", "--short", f"origin/{repo_cfg.branch}"],
|
|
261
|
+
cwd=str(_lp), capture_output=True, text=True, timeout=5,
|
|
262
|
+
).stdout.strip()
|
|
263
|
+
if _local and _remote and _local != _remote:
|
|
264
|
+
deleted = store.invalidate_knowledge(repo_name)
|
|
265
|
+
if deleted:
|
|
266
|
+
logger.info("PR check: HEAD changed for %s (%s→%s), "
|
|
267
|
+
"invalidated %d knowledge cache entries",
|
|
268
|
+
repo_name, _local, _remote, deleted)
|
|
269
|
+
except Exception as _ce:
|
|
270
|
+
logger.debug("Head-change check failed for %s: %s", repo_name, _ce)
|
|
271
|
+
|
|
272
|
+
except Exception as _e:
|
|
273
|
+
logger.debug("PR check error for %s: %s", repo_name, _e)
|
|
274
|
+
continue'''
|
|
275
|
+
|
|
276
|
+
if OLD_CLOSE_PRS not in boss:
|
|
277
|
+
print("ERROR: close PRs block not found in boss")
|
|
278
|
+
sys.exit(1)
|
|
279
|
+
boss = boss.replace(OLD_CLOSE_PRS, NEW_CLOSE_PRS, 1)
|
|
280
|
+
print("Step 5 OK: _pr_check_loop detects HEAD changes and invalidates cache")
|
|
281
|
+
|
|
282
|
+
with open(MAIN, 'w', encoding='utf-8') as f:
|
|
283
|
+
f.write(boss)
|
|
284
|
+
print("Written OK")
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
ast.parse(boss)
|
|
288
|
+
print("main.py Syntax OK")
|
|
289
|
+
except SyntaxError as e:
|
|
290
|
+
lines = boss.splitlines()
|
|
291
|
+
print(f"SyntaxError line {e.lineno}: {e.msg}")
|
|
292
|
+
for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
|
|
293
|
+
print(f" {i+1}: {lines[i]}")
|
|
294
|
+
sys.exit(1)
|
|
@@ -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)
|