@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,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)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Add list_prs and drop_pr tools to sentinel_boss.py.
|
|
4
|
+
Update merge_pr to record admin decision in pull_requests DB on success.
|
|
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
|
+
# ── 1. Insert list_prs + drop_pr tool definitions before merge_pr ─────────────
|
|
14
|
+
|
|
15
|
+
OLD_BEFORE_MERGE_DEF = ''' "input_schema": {"type": "object", "properties": {}},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "merge_pr",'''
|
|
19
|
+
|
|
20
|
+
NEW_BEFORE_MERGE_DEF = ''' "input_schema": {"type": "object", "properties": {}},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "list_prs",
|
|
24
|
+
"description": (
|
|
25
|
+
"ADMIN ONLY. List all tracked pull requests across managed repos. "
|
|
26
|
+
"Shows open PRs waiting for decision, plus recent merges and drops. "
|
|
27
|
+
"Use for: 'show open PRs', 'what PRs are waiting?', 'list renovate PRs', "
|
|
28
|
+
"'what did I merge last week?', 'show all PRs for TypeLib'."
|
|
29
|
+
),
|
|
30
|
+
"input_schema": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"repo": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Filter by repo name (partial match). Omit for all repos.",
|
|
36
|
+
},
|
|
37
|
+
"status": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"enum": ["open", "merged", "closed", "pending"],
|
|
40
|
+
"description": (
|
|
41
|
+
"open = all open PRs, pending = open with no admin decision yet, "
|
|
42
|
+
"merged = merged PRs, closed = dropped/closed without merge. "
|
|
43
|
+
"Omit for all."
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "drop_pr",
|
|
51
|
+
"description": (
|
|
52
|
+
"ADMIN ONLY. Mark a PR as dropped/rejected — do NOT merge it. "
|
|
53
|
+
"Records who dropped it and when. "
|
|
54
|
+
"Use for: 'drop PR #247 in TypeLib', 'reject the renovate PR for Java-SDK', "
|
|
55
|
+
"'close without merging PR #12 in STS'."
|
|
56
|
+
),
|
|
57
|
+
"input_schema": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"repo_name": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "Repository name (must match a repo in config/repos/)",
|
|
63
|
+
},
|
|
64
|
+
"pr_number": {
|
|
65
|
+
"type": "integer",
|
|
66
|
+
"description": "PR number to drop",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
"required": ["repo_name", "pr_number"],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "merge_pr",'''
|
|
74
|
+
|
|
75
|
+
if OLD_BEFORE_MERGE_DEF not in boss:
|
|
76
|
+
print("ERROR: anchor before merge_pr not found")
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
boss = boss.replace(OLD_BEFORE_MERGE_DEF, NEW_BEFORE_MERGE_DEF, 1)
|
|
79
|
+
print("Step 1 OK: list_prs + drop_pr tool definitions added")
|
|
80
|
+
|
|
81
|
+
# ── 2. Add handlers for list_prs and drop_pr before the merge_pr handler ──────
|
|
82
|
+
|
|
83
|
+
OLD_MERGE_HANDLER_START = ''' if name == "merge_pr":
|
|
84
|
+
import re as _re
|
|
85
|
+
import requests as _req
|
|
86
|
+
repo_name = inputs.get("repo_name", "").strip()'''
|
|
87
|
+
|
|
88
|
+
NEW_MERGE_HANDLER_START = ''' if name == "list_prs":
|
|
89
|
+
if not is_admin:
|
|
90
|
+
return json.dumps({"error": "Admin access required."})
|
|
91
|
+
repo_filter = inputs.get("repo", "").strip()
|
|
92
|
+
status_filter = inputs.get("status", "").strip()
|
|
93
|
+
# "pending" = open with no admin decision
|
|
94
|
+
if status_filter == "pending":
|
|
95
|
+
prs = store.get_prs(repo_name=repo_filter, state="open", decision="pending")
|
|
96
|
+
elif status_filter:
|
|
97
|
+
prs = store.get_prs(repo_name=repo_filter, state=status_filter)
|
|
98
|
+
else:
|
|
99
|
+
prs = store.get_prs(repo_name=repo_filter)
|
|
100
|
+
if not prs:
|
|
101
|
+
return json.dumps({"message": "No PRs found matching the filter.", "prs": []})
|
|
102
|
+
# Format for readability
|
|
103
|
+
formatted = []
|
|
104
|
+
for p in prs:
|
|
105
|
+
entry = {
|
|
106
|
+
"repo": p["repo_name"],
|
|
107
|
+
"pr_number": p["pr_number"],
|
|
108
|
+
"title": p["title"],
|
|
109
|
+
"author": p["author"],
|
|
110
|
+
"source": p["source"],
|
|
111
|
+
"state": p["pr_state"],
|
|
112
|
+
"url": p["pr_url"],
|
|
113
|
+
"first_seen": p["first_seen"][:10] if p["first_seen"] else "",
|
|
114
|
+
}
|
|
115
|
+
if p.get("admin_decision"):
|
|
116
|
+
entry["decision"] = p["admin_decision"]
|
|
117
|
+
entry["decided_by"] = p.get("admin_user_id", "")
|
|
118
|
+
entry["decided_at"] = (p.get("admin_decided_at") or "")[:10]
|
|
119
|
+
else:
|
|
120
|
+
entry["decision"] = "pending"
|
|
121
|
+
formatted.append(entry)
|
|
122
|
+
return json.dumps({"total": len(formatted), "prs": formatted})
|
|
123
|
+
|
|
124
|
+
if name == "drop_pr":
|
|
125
|
+
if not is_admin:
|
|
126
|
+
return json.dumps({"error": "Admin access required."})
|
|
127
|
+
repo_name = inputs.get("repo_name", "").strip()
|
|
128
|
+
pr_number = inputs.get("pr_number")
|
|
129
|
+
if not repo_name or not pr_number:
|
|
130
|
+
return json.dumps({"error": "repo_name and pr_number are required"})
|
|
131
|
+
pr_number = int(pr_number)
|
|
132
|
+
# Fuzzy match repo name
|
|
133
|
+
if repo_name not in cfg_loader.repos:
|
|
134
|
+
for rname in cfg_loader.repos:
|
|
135
|
+
if repo_name.lower() in rname.lower():
|
|
136
|
+
repo_name = rname
|
|
137
|
+
break
|
|
138
|
+
store.record_pr_decision(repo_name, pr_number, "rejected", user_id)
|
|
139
|
+
logger.info("Boss drop_pr: PR #%d for %s dropped by admin %s", pr_number, repo_name, user_id)
|
|
140
|
+
return json.dumps({
|
|
141
|
+
"status": "dropped",
|
|
142
|
+
"repo": repo_name,
|
|
143
|
+
"pr_number": pr_number,
|
|
144
|
+
"dropped_by": user_id,
|
|
145
|
+
"note": f"PR #{pr_number} marked as dropped. It will not be re-notified.",
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if name == "merge_pr":
|
|
149
|
+
import re as _re
|
|
150
|
+
import requests as _req
|
|
151
|
+
repo_name = inputs.get("repo_name", "").strip()'''
|
|
152
|
+
|
|
153
|
+
if OLD_MERGE_HANDLER_START not in boss:
|
|
154
|
+
print("ERROR: merge_pr handler start not found")
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
boss = boss.replace(OLD_MERGE_HANDLER_START, NEW_MERGE_HANDLER_START, 1)
|
|
157
|
+
print("Step 2 OK: list_prs + drop_pr handlers added")
|
|
158
|
+
|
|
159
|
+
# ── 3. Record admin decision in merge_pr Path A on success ───────────────────
|
|
160
|
+
|
|
161
|
+
OLD_PATH_A_SUCCESS = ''' if merge_resp.status_code == 200:
|
|
162
|
+
sha = merge_resp.json().get("sha", "")[:8]
|
|
163
|
+
logger.info("Boss merge_pr: merged PR #%d for %s by admin %s", pr_number_in, repo_name, user_id)
|
|
164
|
+
return json.dumps({
|
|
165
|
+
"status": "merged",
|
|
166
|
+
"pr": pr_url,
|
|
167
|
+
"pr_number": pr_number_in,
|
|
168
|
+
"title": pr_title,
|
|
169
|
+
"sha": sha,
|
|
170
|
+
"repo": repo_name,
|
|
171
|
+
"note": f"PR #{pr_number_in} merged. Branch '{branch}' can now be deleted.",
|
|
172
|
+
})'''
|
|
173
|
+
|
|
174
|
+
NEW_PATH_A_SUCCESS = ''' if merge_resp.status_code == 200:
|
|
175
|
+
sha = merge_resp.json().get("sha", "")[:8]
|
|
176
|
+
store.record_pr_decision(repo_name, pr_number_in, "merged", user_id)
|
|
177
|
+
logger.info("Boss merge_pr: merged PR #%d for %s by admin %s", pr_number_in, repo_name, user_id)
|
|
178
|
+
return json.dumps({
|
|
179
|
+
"status": "merged",
|
|
180
|
+
"pr": pr_url,
|
|
181
|
+
"pr_number": pr_number_in,
|
|
182
|
+
"title": pr_title,
|
|
183
|
+
"sha": sha,
|
|
184
|
+
"repo": repo_name,
|
|
185
|
+
"note": f"PR #{pr_number_in} merged. Branch '{branch}' can now be deleted.",
|
|
186
|
+
})'''
|
|
187
|
+
|
|
188
|
+
if OLD_PATH_A_SUCCESS not in boss:
|
|
189
|
+
print("ERROR: Path A success block not found")
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
boss = boss.replace(OLD_PATH_A_SUCCESS, NEW_PATH_A_SUCCESS, 1)
|
|
192
|
+
print("Step 3 OK: Path A merge records decision in PR table")
|
|
193
|
+
|
|
194
|
+
# ── 4. Record admin decision in merge_pr Path B on success ───────────────────
|
|
195
|
+
|
|
196
|
+
OLD_PATH_B_SUCCESS = ''' if merge_resp.status_code == 200:
|
|
197
|
+
# Success
|
|
198
|
+
sha = merge_resp.json().get("sha", "")[:8]
|
|
199
|
+
store.record_fix(fp, "applied", branch=branch, pr_url=pr_url,
|
|
200
|
+
repo_name=repo_name, commit_hash=sha)
|
|
201
|
+
logger.info("Boss merge_pr: merged PR #%s for %s (fp=%s) by admin %s",
|
|
202
|
+
pr_number, repo_name, fp[:8], user_id)
|
|
203
|
+
return json.dumps({
|
|
204
|
+
"status": "merged",
|
|
205
|
+
"pr": pr_url,
|
|
206
|
+
"sha": sha,
|
|
207
|
+
"repo": repo_name,
|
|
208
|
+
"note": f"PR #{pr_number} merged into main. Branch '{branch}' can now be deleted.",
|
|
209
|
+
})'''
|
|
210
|
+
|
|
211
|
+
NEW_PATH_B_SUCCESS = ''' if merge_resp.status_code == 200:
|
|
212
|
+
# Success
|
|
213
|
+
sha = merge_resp.json().get("sha", "")[:8]
|
|
214
|
+
store.record_fix(fp, "applied", branch=branch, pr_url=pr_url,
|
|
215
|
+
repo_name=repo_name, commit_hash=sha)
|
|
216
|
+
try:
|
|
217
|
+
store.record_pr_decision(repo_name, int(pr_number), "merged", user_id)
|
|
218
|
+
except Exception:
|
|
219
|
+
pass # PR may not be in tracking table yet — non-fatal
|
|
220
|
+
logger.info("Boss merge_pr: merged PR #%s for %s (fp=%s) by admin %s",
|
|
221
|
+
pr_number, repo_name, fp[:8], user_id)
|
|
222
|
+
return json.dumps({
|
|
223
|
+
"status": "merged",
|
|
224
|
+
"pr": pr_url,
|
|
225
|
+
"sha": sha,
|
|
226
|
+
"repo": repo_name,
|
|
227
|
+
"note": f"PR #{pr_number} merged into main. Branch '{branch}' can now be deleted.",
|
|
228
|
+
})'''
|
|
229
|
+
|
|
230
|
+
if OLD_PATH_B_SUCCESS not in boss:
|
|
231
|
+
print("ERROR: Path B success block not found")
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
boss = boss.replace(OLD_PATH_B_SUCCESS, NEW_PATH_B_SUCCESS, 1)
|
|
234
|
+
print("Step 4 OK: Path B merge records decision in PR table")
|
|
235
|
+
|
|
236
|
+
# ── 5. Update tool list header comment to mention new tools ──────────────────
|
|
237
|
+
# Find the line that has "merge_pr" in the boss description list
|
|
238
|
+
|
|
239
|
+
OLD_TOOL_LIST = '''19. ask_codebase — Ask any natural-language question about a managed repo's codebase.'''
|
|
240
|
+
NEW_TOOL_LIST = '''19. ask_codebase — Ask any natural-language question about a managed repo's codebase.
|
|
241
|
+
20. list_prs — List tracked PRs (open, pending decision, merged, dropped). Admin only.
|
|
242
|
+
21. drop_pr — Mark a PR as dropped/rejected without merging. Admin only.'''
|
|
243
|
+
|
|
244
|
+
if OLD_TOOL_LIST not in boss:
|
|
245
|
+
print("WARNING: tool list comment not found — skipping (non-critical)")
|
|
246
|
+
else:
|
|
247
|
+
boss = boss.replace(OLD_TOOL_LIST, NEW_TOOL_LIST, 1)
|
|
248
|
+
print("Step 5 OK: tool list comment updated")
|
|
249
|
+
|
|
250
|
+
with open(BOSS, 'w', encoding='utf-8') as f:
|
|
251
|
+
f.write(boss)
|
|
252
|
+
print("Written OK")
|
|
253
|
+
|
|
254
|
+
# ── Syntax check ──────────────────────────────────────────────────────────────
|
|
255
|
+
with open(BOSS, 'r', encoding='utf-8') as f:
|
|
256
|
+
src = f.read()
|
|
257
|
+
try:
|
|
258
|
+
ast.parse(src)
|
|
259
|
+
print("Syntax OK")
|
|
260
|
+
except SyntaxError as e:
|
|
261
|
+
lines = src.splitlines()
|
|
262
|
+
print(f"SyntaxError at line {e.lineno}: {e.msg}")
|
|
263
|
+
for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
|
|
264
|
+
print(f" {i+1}: {lines[i]}")
|
|
265
|
+
sys.exit(1)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Add pull_requests table and methods to state_store.py:
|
|
4
|
+
- CREATE TABLE pull_requests (tracks ALL open PRs across managed repos)
|
|
5
|
+
- upsert_pr / mark_pr_notified / record_pr_decision / get_prs / get_prs_for_notification
|
|
6
|
+
"""
|
|
7
|
+
import ast, sys
|
|
8
|
+
|
|
9
|
+
STORE = '/home/sentinel/sentinel/code/sentinel/state_store.py'
|
|
10
|
+
|
|
11
|
+
with open(STORE, 'r', encoding='utf-8') as f:
|
|
12
|
+
src = f.read()
|
|
13
|
+
|
|
14
|
+
# ── 1. Add migration entry for pull_requests table ───────────────────────────
|
|
15
|
+
|
|
16
|
+
OLD_MIGRATE_BLOCK = ''' migrations = [
|
|
17
|
+
("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
|
|
18
|
+
("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
|
|
19
|
+
("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
|
|
20
|
+
("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
|
|
21
|
+
("add_watched_bots_project", "ALTER TABLE watched_bots ADD COLUMN project_name TEXT"),
|
|
22
|
+
]'''
|
|
23
|
+
|
|
24
|
+
NEW_MIGRATE_BLOCK = ''' migrations = [
|
|
25
|
+
("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
|
|
26
|
+
("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
|
|
27
|
+
("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
|
|
28
|
+
("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
|
|
29
|
+
("add_watched_bots_project", "ALTER TABLE watched_bots ADD COLUMN project_name TEXT"),
|
|
30
|
+
("create_pull_requests",
|
|
31
|
+
"""CREATE TABLE IF NOT EXISTS pull_requests (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
repo_name TEXT NOT NULL,
|
|
34
|
+
pr_number INTEGER NOT NULL,
|
|
35
|
+
pr_url TEXT NOT NULL,
|
|
36
|
+
title TEXT,
|
|
37
|
+
author TEXT,
|
|
38
|
+
head_branch TEXT,
|
|
39
|
+
base_branch TEXT,
|
|
40
|
+
source TEXT,
|
|
41
|
+
pr_state TEXT DEFAULT 'open',
|
|
42
|
+
first_seen TEXT NOT NULL,
|
|
43
|
+
last_seen TEXT NOT NULL,
|
|
44
|
+
notified_at TEXT,
|
|
45
|
+
admin_decision TEXT,
|
|
46
|
+
admin_user_id TEXT,
|
|
47
|
+
admin_decided_at TEXT,
|
|
48
|
+
UNIQUE(repo_name, pr_number)
|
|
49
|
+
)"""),
|
|
50
|
+
]'''
|
|
51
|
+
|
|
52
|
+
if OLD_MIGRATE_BLOCK not in src:
|
|
53
|
+
print("ERROR: migrations block not found")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
src = src.replace(OLD_MIGRATE_BLOCK, NEW_MIGRATE_BLOCK, 1)
|
|
56
|
+
print("Step 1 OK: migration entry added")
|
|
57
|
+
|
|
58
|
+
# ── 2. Fix migration runner to handle multi-line SQL ─────────────────────────
|
|
59
|
+
# The existing runner tries conn.execute(sql) which fails for CREATE TABLE.
|
|
60
|
+
# Replace with a try/except that skips DDL errors and always marks done.
|
|
61
|
+
|
|
62
|
+
OLD_MIGRATE_RUN = ''' with self._conn() as conn:
|
|
63
|
+
done = {r[0] for r in conn.execute("SELECT name FROM _sentinel_migrations").fetchall()}
|
|
64
|
+
for name, sql in migrations:
|
|
65
|
+
if name not in done:
|
|
66
|
+
try:
|
|
67
|
+
conn.execute(sql)
|
|
68
|
+
conn.execute("INSERT INTO _sentinel_migrations VALUES (?)", (name,))
|
|
69
|
+
logger.debug("Migration applied: %s", name)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass # column may already exist'''
|
|
72
|
+
|
|
73
|
+
NEW_MIGRATE_RUN = ''' with self._conn() as conn:
|
|
74
|
+
done = {r[0] for r in conn.execute("SELECT name FROM _sentinel_migrations").fetchall()}
|
|
75
|
+
for name, sql in migrations:
|
|
76
|
+
if name not in done:
|
|
77
|
+
try:
|
|
78
|
+
conn.executescript(sql) if sql.strip().upper().startswith("CREATE") else conn.execute(sql)
|
|
79
|
+
conn.execute("INSERT INTO _sentinel_migrations VALUES (?)", (name,))
|
|
80
|
+
conn.commit()
|
|
81
|
+
logger.debug("Migration applied: %s", name)
|
|
82
|
+
except Exception as _me:
|
|
83
|
+
logger.debug("Migration skipped %s: %s", name, _me)
|
|
84
|
+
pass # column / table may already exist'''
|
|
85
|
+
|
|
86
|
+
if OLD_MIGRATE_RUN not in src:
|
|
87
|
+
print("ERROR: migration runner not found")
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
src = src.replace(OLD_MIGRATE_RUN, NEW_MIGRATE_RUN, 1)
|
|
90
|
+
print("Step 2 OK: migration runner updated")
|
|
91
|
+
|
|
92
|
+
# ── 3. Add PR tracking methods (insert before the last method / end of class) ─
|
|
93
|
+
# Find a good anchor — the delete_fix method or end of class
|
|
94
|
+
|
|
95
|
+
ANCHOR = ''' def get_all_errors(self, hours: int = 0) -> list[dict]:'''
|
|
96
|
+
|
|
97
|
+
if ANCHOR not in src:
|
|
98
|
+
print("ERROR: anchor method not found")
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
NEW_METHODS = ''' # ── Pull-request tracking ────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def upsert_pr(self, repo_name: str, pr_number: int, pr_url: str,
|
|
104
|
+
title: str = "", author: str = "", head_branch: str = "",
|
|
105
|
+
base_branch: str = "main", source: str = "external",
|
|
106
|
+
pr_state: str = "open") -> bool:
|
|
107
|
+
"""Insert or update a PR record. Returns True if this is a NEW (unseen) PR."""
|
|
108
|
+
now = _now()
|
|
109
|
+
with self._conn() as conn:
|
|
110
|
+
existing = conn.execute(
|
|
111
|
+
"SELECT id, notified_at FROM pull_requests WHERE repo_name=? AND pr_number=?",
|
|
112
|
+
(repo_name, pr_number),
|
|
113
|
+
).fetchone()
|
|
114
|
+
if existing:
|
|
115
|
+
conn.execute(
|
|
116
|
+
"UPDATE pull_requests SET pr_state=?, title=?, last_seen=? "
|
|
117
|
+
"WHERE repo_name=? AND pr_number=?",
|
|
118
|
+
(pr_state, title, now, repo_name, pr_number),
|
|
119
|
+
)
|
|
120
|
+
return False # not new
|
|
121
|
+
conn.execute(
|
|
122
|
+
"INSERT INTO pull_requests "
|
|
123
|
+
"(repo_name, pr_number, pr_url, title, author, head_branch, base_branch, "
|
|
124
|
+
" source, pr_state, first_seen, last_seen) "
|
|
125
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
|
126
|
+
(repo_name, pr_number, pr_url, title, author, head_branch, base_branch,
|
|
127
|
+
source, pr_state, now, now),
|
|
128
|
+
)
|
|
129
|
+
return True # new PR
|
|
130
|
+
|
|
131
|
+
def get_prs_for_notification(self) -> list[dict]:
|
|
132
|
+
"""PRs that are open and have not yet been notified to admins."""
|
|
133
|
+
with self._conn() as conn:
|
|
134
|
+
rows = conn.execute(
|
|
135
|
+
"SELECT * FROM pull_requests WHERE pr_state='open' AND notified_at IS NULL "
|
|
136
|
+
"ORDER BY first_seen ASC"
|
|
137
|
+
).fetchall()
|
|
138
|
+
return [dict(r) for r in rows]
|
|
139
|
+
|
|
140
|
+
def mark_pr_notified(self, repo_name: str, pr_number: int) -> None:
|
|
141
|
+
with self._conn() as conn:
|
|
142
|
+
conn.execute(
|
|
143
|
+
"UPDATE pull_requests SET notified_at=? WHERE repo_name=? AND pr_number=?",
|
|
144
|
+
(_now(), repo_name, pr_number),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def record_pr_decision(self, repo_name: str, pr_number: int,
|
|
148
|
+
decision: str, user_id: str) -> None:
|
|
149
|
+
"""Record admin decision: merged | rejected | dropped."""
|
|
150
|
+
with self._conn() as conn:
|
|
151
|
+
conn.execute(
|
|
152
|
+
"UPDATE pull_requests SET admin_decision=?, admin_user_id=?, "
|
|
153
|
+
"admin_decided_at=?, pr_state=? "
|
|
154
|
+
"WHERE repo_name=? AND pr_number=?",
|
|
155
|
+
(decision,
|
|
156
|
+
user_id,
|
|
157
|
+
_now(),
|
|
158
|
+
"merged" if decision == "merged" else "closed",
|
|
159
|
+
repo_name,
|
|
160
|
+
pr_number),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def get_prs(self, repo_name: str = "", state: str = "",
|
|
164
|
+
decision: str = "") -> list[dict]:
|
|
165
|
+
"""List tracked PRs with optional filters."""
|
|
166
|
+
clauses, params = [], []
|
|
167
|
+
if repo_name:
|
|
168
|
+
clauses.append("repo_name LIKE ?")
|
|
169
|
+
params.append(f"%{repo_name}%")
|
|
170
|
+
if state:
|
|
171
|
+
clauses.append("pr_state=?")
|
|
172
|
+
params.append(state)
|
|
173
|
+
if decision == "pending":
|
|
174
|
+
clauses.append("admin_decision IS NULL AND pr_state='open'")
|
|
175
|
+
elif decision:
|
|
176
|
+
clauses.append("admin_decision=?")
|
|
177
|
+
params.append(decision)
|
|
178
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
179
|
+
with self._conn() as conn:
|
|
180
|
+
rows = conn.execute(
|
|
181
|
+
f"SELECT * FROM pull_requests {where} ORDER BY first_seen DESC LIMIT 100",
|
|
182
|
+
params,
|
|
183
|
+
).fetchall()
|
|
184
|
+
return [dict(r) for r in rows]
|
|
185
|
+
|
|
186
|
+
def close_pr(self, repo_name: str, pr_number: int, state: str = "closed") -> None:
|
|
187
|
+
"""Mark a tracked PR as closed/merged (from external GitHub event)."""
|
|
188
|
+
with self._conn() as conn:
|
|
189
|
+
conn.execute(
|
|
190
|
+
"UPDATE pull_requests SET pr_state=?, last_seen=? "
|
|
191
|
+
"WHERE repo_name=? AND pr_number=?",
|
|
192
|
+
(state, _now(), repo_name, pr_number),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
'''
|
|
196
|
+
|
|
197
|
+
src = src.replace(ANCHOR, NEW_METHODS + "\n " + ANCHOR, 1)
|
|
198
|
+
print("Step 3 OK: PR tracking methods added")
|
|
199
|
+
|
|
200
|
+
with open(STORE, 'w', encoding='utf-8') as f:
|
|
201
|
+
f.write(src)
|
|
202
|
+
print("Written OK")
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
ast.parse(src)
|
|
206
|
+
print("Syntax OK")
|
|
207
|
+
except SyntaxError as e:
|
|
208
|
+
lines = src.splitlines()
|
|
209
|
+
print(f"SyntaxError at line {e.lineno}: {e.msg}")
|
|
210
|
+
for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
|
|
211
|
+
print(f" {i+1}: {lines[i]}")
|
|
212
|
+
sys.exit(1)
|