@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.
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Add _pr_check_loop to main.py:
4
+ - Polls GitHub API every 30 min for open PRs across all managed repos
5
+ - Upserts into pull_requests table
6
+ - Posts one-time Slack notification per new PR to admin channel
7
+ - Closes PRs in DB that are no longer open on GitHub
8
+ """
9
+ import ast, sys
10
+
11
+ MAIN = '/home/sentinel/sentinel/code/sentinel/main.py'
12
+
13
+ with open(MAIN, 'r', encoding='utf-8') as f:
14
+ src = f.read()
15
+
16
+ # ── 1. Add the _pr_check_loop function before run_loop ────────────────────────
17
+
18
+ ANCHOR = '''async def run_loop(cfg_loader: ConfigLoader, store: StateStore):'''
19
+
20
+ if ANCHOR not in src:
21
+ print("ERROR: run_loop anchor not found")
22
+ sys.exit(1)
23
+
24
+ PR_LOOP = '''async def _pr_check_loop(cfg_loader: ConfigLoader, store: StateStore) -> None:
25
+ """
26
+ Poll GitHub for open PRs across all managed repos every 30 min.
27
+ New PRs are saved to pull_requests table and admins are notified once.
28
+ """
29
+ import re as _re
30
+ import requests as _req
31
+
32
+ CHECK_INTERVAL = 1800 # 30 minutes
33
+
34
+ def _classify_source(pr: dict) -> str:
35
+ author = (pr.get("user") or {}).get("login", "")
36
+ head = (pr.get("head") or {}).get("ref", "")
37
+ if author == "renovate[bot]" or author.startswith("renovate"):
38
+ return "renovate"
39
+ if head.startswith("sentinel/fix-"):
40
+ return "sentinel-fix"
41
+ return "external"
42
+
43
+ def _owner_repo(repo_cfg) -> str:
44
+ url = getattr(repo_cfg, "repo_url", "") or ""
45
+ if url.startswith("git@"):
46
+ return url.split(":")[-1].removesuffix(".git")
47
+ parts = url.rstrip("/").split("/")
48
+ return "/".join(parts[-2:]).removesuffix(".git")
49
+
50
+ await asyncio.sleep(60) # short delay at startup so everything else is ready
51
+
52
+ while True:
53
+ cfg = cfg_loader.sentinel
54
+ gh_token = cfg.github_token
55
+ if not gh_token:
56
+ await asyncio.sleep(CHECK_INTERVAL)
57
+ continue
58
+
59
+ headers = {
60
+ "Authorization": f"Bearer {gh_token}",
61
+ "Accept": "application/vnd.github+json",
62
+ }
63
+
64
+ for repo_name, repo_cfg in cfg_loader.repos.items():
65
+ owner_repo = _owner_repo(repo_cfg)
66
+ if not owner_repo or "/" not in owner_repo:
67
+ continue
68
+ try:
69
+ resp = _req.get(
70
+ f"https://api.github.com/repos/{owner_repo}/pulls?state=open&per_page=50",
71
+ headers=headers, timeout=15,
72
+ )
73
+ if resp.status_code != 200:
74
+ logger.debug("PR check %s: HTTP %s", repo_name, resp.status_code)
75
+ continue
76
+
77
+ open_prs = resp.json()
78
+ seen_nums = set()
79
+ for pr in open_prs:
80
+ num = pr["number"]
81
+ url = pr["html_url"]
82
+ title = pr.get("title", "")
83
+ author = (pr.get("user") or {}).get("login", "unknown")
84
+ head = (pr.get("head") or {}).get("ref", "")
85
+ base = (pr.get("base") or {}).get("ref", "main")
86
+ source = _classify_source(pr)
87
+ seen_nums.add(num)
88
+
89
+ is_new = store.upsert_pr(
90
+ repo_name=repo_name, pr_number=num, pr_url=url,
91
+ title=title, author=author, head_branch=head,
92
+ base_branch=base, source=source, pr_state="open",
93
+ )
94
+ if is_new:
95
+ logger.info("PR check: new PR %s #%d (%s) by %s", repo_name, num, source, author)
96
+
97
+ # Mark PRs in DB that are no longer open on GitHub
98
+ all_tracked = store.get_prs(repo_name=repo_name, state="open")
99
+ for tracked in all_tracked:
100
+ if tracked["pr_number"] not in seen_nums:
101
+ store.close_pr(repo_name, tracked["pr_number"], state="closed")
102
+ logger.info("PR check: closed PR %s #%d (no longer open on GitHub)",
103
+ repo_name, tracked["pr_number"])
104
+
105
+ except Exception as _e:
106
+ logger.debug("PR check error for %s: %s", repo_name, _e)
107
+ continue
108
+
109
+ # Notify admins of any unnotified new PRs (one message per PR)
110
+ to_notify = store.get_prs_for_notification()
111
+ if to_notify and cfg.slack_bot_token and cfg.slack_channel:
112
+ from .notify import slack_alert
113
+ for pr in to_notify:
114
+ source_tag = {
115
+ "renovate": ":dependabot: Renovate",
116
+ "sentinel-fix": ":robot_face: Sentinel fix",
117
+ "external": ":octocat: External",
118
+ }.get(pr.get("source", "external"), ":octocat: PR")
119
+ _nl = "\\n"
120
+ msg = (
121
+ f":bell: *New PR* \\u2014 `{pr['repo_name']}` #{pr['pr_number']} [{source_tag}]{_nl}"
122
+ f"*{pr['title']}*{_nl}"
123
+ f"By `{pr['author']}` on branch `{pr['head_branch']}`{_nl}"
124
+ f"{pr['pr_url']}{_nl}"
125
+ f"_Reply: `list prs` to review, then `merge PR #{pr['pr_number']} in {pr['repo_name']}` "
126
+ f"or `drop PR #{pr['pr_number']} in {pr['repo_name']}`_"
127
+ )
128
+ try:
129
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
130
+ store.mark_pr_notified(pr['repo_name'], pr['pr_number'])
131
+ logger.info("PR notified: %s #%d", pr['repo_name'], pr['pr_number'])
132
+ except Exception as _ne:
133
+ logger.warning("Failed to notify PR %s #%d: %s", pr['repo_name'], pr['pr_number'], _ne)
134
+
135
+ await asyncio.sleep(CHECK_INTERVAL)
136
+
137
+
138
+ '''
139
+
140
+ src = src.replace(ANCHOR, PR_LOOP + ANCHOR, 1)
141
+ print("Step 1 OK: _pr_check_loop added")
142
+
143
+ # ── 2. Start the loop in run_loop ─────────────────────────────────────────────
144
+
145
+ OLD_START = ''' if cfg_loader.sentinel.slack_bot_token:
146
+ from .slack_bot import run_slack_bot
147
+ asyncio.ensure_future(run_slack_bot(cfg_loader, store))'''
148
+
149
+ NEW_START = ''' if cfg_loader.sentinel.slack_bot_token:
150
+ from .slack_bot import run_slack_bot
151
+ asyncio.ensure_future(run_slack_bot(cfg_loader, store))
152
+
153
+ if cfg_loader.sentinel.github_token:
154
+ asyncio.ensure_future(_pr_check_loop(cfg_loader, store))'''
155
+
156
+ if OLD_START not in src:
157
+ print("ERROR: slack_bot start anchor not found")
158
+ sys.exit(1)
159
+ src = src.replace(OLD_START, NEW_START, 1)
160
+ print("Step 2 OK: _pr_check_loop started in run_loop")
161
+
162
+ with open(MAIN, 'w', encoding='utf-8') as f:
163
+ f.write(src)
164
+ print("Written OK")
165
+
166
+ try:
167
+ ast.parse(src)
168
+ print("Syntax OK")
169
+ except SyntaxError as e:
170
+ lines = src.splitlines()
171
+ print(f"SyntaxError at line {e.lineno}: {e.msg}")
172
+ for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
173
+ print(f" {i+1}: {lines[i]}")
174
+ sys.exit(1)
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Project isolation + identity:
4
+ 1. Add SLACK_WORKSPACE_ID + PROJECT_DESCRIPTION to config_loader
5
+ 2. Verify workspace_id on every incoming Slack event in slack_bot.py
6
+ 3. Inject project identity + scope isolation into the runtime system prompt
7
+ 4. Boss refuses cross-project requests by design
8
+ """
9
+ import ast, sys
10
+
11
+ CODE = '/home/sentinel/sentinel/code/sentinel'
12
+
13
+ # ── 1. Add fields to SentinelConfig in config_loader.py ──────────────────────
14
+
15
+ with open(f'{CODE}/config_loader.py', 'r', encoding='utf-8') as f:
16
+ cfg_src = f.read()
17
+
18
+ OLD_CFG = ''' project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")'''
19
+
20
+ NEW_CFG = ''' project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
21
+ project_description: str = "" # short description of what this project is/does
22
+ slack_workspace_id: str = "" # Slack team_id (T...) — if set, reject events from other workspaces'''
23
+
24
+ if OLD_CFG not in cfg_src:
25
+ print("ERROR: project_name field not found in config_loader")
26
+ sys.exit(1)
27
+ cfg_src = cfg_src.replace(OLD_CFG, NEW_CFG, 1)
28
+
29
+ OLD_LOAD = ''' c.project_name = d.get("PROJECT_NAME", "") or Path(self.config_dir).resolve().parent.name'''
30
+ NEW_LOAD = ''' c.project_name = d.get("PROJECT_NAME", "") or Path(self.config_dir).resolve().parent.name
31
+ c.project_description = d.get("PROJECT_DESCRIPTION", "")
32
+ c.slack_workspace_id = d.get("SLACK_WORKSPACE_ID", "").strip()'''
33
+
34
+ if OLD_LOAD not in cfg_src:
35
+ print("ERROR: project_name load line not found in config_loader")
36
+ sys.exit(1)
37
+ cfg_src = cfg_src.replace(OLD_LOAD, NEW_LOAD, 1)
38
+
39
+ with open(f'{CODE}/config_loader.py', 'w', encoding='utf-8') as f:
40
+ f.write(cfg_src)
41
+ try:
42
+ ast.parse(cfg_src)
43
+ print("Step 1 OK: SLACK_WORKSPACE_ID + PROJECT_DESCRIPTION added to config")
44
+ except SyntaxError as e:
45
+ print(f"SyntaxError config_loader line {e.lineno}: {e.msg}"); sys.exit(1)
46
+
47
+ # ── 2. Workspace verification in slack_bot.py ─────────────────────────────────
48
+
49
+ with open(f'{CODE}/slack_bot.py', 'r', encoding='utf-8') as f:
50
+ bot_src = f.read()
51
+
52
+ # Inject workspace check right after the user_id / allowlist check in _dispatch
53
+ OLD_DISPATCH_CHECK = ''' # Allowlist check — if SLACK_ALLOWED_USERS is configured, silently ignore everyone else
54
+ allowed = cfg_loader.sentinel.slack_allowed_users
55
+ if allowed and user_id not in allowed:
56
+ logger.warning("Boss: ignoring message from unauthorised user %s", user_id)
57
+ return'''
58
+
59
+ NEW_DISPATCH_CHECK = ''' # Workspace isolation — if SLACK_WORKSPACE_ID is set, reject events from other workspaces
60
+ expected_workspace = cfg_loader.sentinel.slack_workspace_id
61
+ if expected_workspace:
62
+ event_team = event.get("team") or event.get("team_id", "")
63
+ if event_team and event_team != expected_workspace:
64
+ logger.warning(
65
+ "Boss: ignoring event from workspace %s (expected %s) — user %s",
66
+ event_team, expected_workspace, user_id,
67
+ )
68
+ return
69
+
70
+ # Allowlist check — if SLACK_ALLOWED_USERS is configured, silently ignore everyone else
71
+ allowed = cfg_loader.sentinel.slack_allowed_users
72
+ if allowed and user_id not in allowed:
73
+ logger.warning("Boss: ignoring message from unauthorised user %s", user_id)
74
+ return'''
75
+
76
+ if OLD_DISPATCH_CHECK not in bot_src:
77
+ print("ERROR: allowlist check anchor not found in slack_bot")
78
+ sys.exit(1)
79
+ bot_src = bot_src.replace(OLD_DISPATCH_CHECK, NEW_DISPATCH_CHECK, 1)
80
+
81
+ with open(f'{CODE}/slack_bot.py', 'w', encoding='utf-8') as f:
82
+ f.write(bot_src)
83
+ try:
84
+ ast.parse(bot_src)
85
+ print("Step 2 OK: workspace isolation check added to slack_bot._dispatch")
86
+ except SyntaxError as e:
87
+ print(f"SyntaxError slack_bot line {e.lineno}: {e.msg}"); sys.exit(1)
88
+
89
+ # ── 3. Inject project identity into the runtime system prompt ─────────────────
90
+
91
+ with open(f'{CODE}/sentinel_boss.py', 'r', encoding='utf-8') as f:
92
+ boss = f.read()
93
+
94
+ # Update _resolve_system to accept project context and prepend it
95
+ OLD_RESOLVE = '''def _resolve_system(boss_mode: str = "standard") -> str:
96
+ hint = _BOSS_MODE_HINTS.get(boss_mode, _BOSS_MODE_HINTS["standard"])
97
+ return _SYSTEM.replace("{BOSS_MODE_HINT}", hint)'''
98
+
99
+ NEW_RESOLVE = '''def _resolve_system(boss_mode: str = "standard",
100
+ project_name: str = "",
101
+ project_description: str = "",
102
+ other_project_names: list | None = None) -> str:
103
+ """Build the system prompt, prepending a project-identity block."""
104
+ hint = _BOSS_MODE_HINTS.get(boss_mode, _BOSS_MODE_HINTS["standard"])
105
+ base = _SYSTEM.replace("{BOSS_MODE_HINT}", hint)
106
+
107
+ if not project_name:
108
+ return base
109
+
110
+ # Project identity header — injected at the very top
111
+ desc_line = f"\\nProject description: {project_description}" if project_description else ""
112
+ others = [n for n in (other_project_names or []) if n.lower() != project_name.lower()]
113
+ if others:
114
+ scope_line = (
115
+ f"\\n\\nSCOPE ISOLATION (important): You serve ONLY the {project_name} project. "
116
+ f"This Sentinel host also runs instances for: {', '.join(others)}. "
117
+ f"If a user asks about {', '.join(others)} or any other project not in your repos, "
118
+ f"decline and explain you are scoped to {project_name} only. "
119
+ f"Never expose config, logs, errors, or code from other projects."
120
+ )
121
+ else:
122
+ scope_line = (
123
+ f"\\n\\nSCOPE: You serve ONLY the {project_name} project. "
124
+ f"Decline requests about projects or repos you do not manage."
125
+ )
126
+
127
+ identity = (
128
+ f"PROJECT IDENTITY\\n"
129
+ f"You are Sentinel Boss for: {project_name}{desc_line}"
130
+ f"{scope_line}\\n"
131
+ f"{'=' * 60}\\n\\n"
132
+ )
133
+ return identity + base'''
134
+
135
+ if OLD_RESOLVE not in boss:
136
+ print("ERROR: _resolve_system not found in boss")
137
+ sys.exit(1)
138
+ boss = boss.replace(OLD_RESOLVE, NEW_RESOLVE, 1)
139
+ print("Step 3a OK: _resolve_system updated to accept project context")
140
+
141
+ # Update both call sites of _resolve_system to pass project + other-projects context
142
+ # There are two call sites (CLI mode ~3711 and API mode ~3910)
143
+ # We'll update the API mode one first — it has access to cfg_loader
144
+
145
+ OLD_SYSTEM_CALL_API = ''' system = (
146
+ _resolve_system(getattr(cfg_loader.sentinel, "boss_mode", "standard"))'''
147
+
148
+ NEW_SYSTEM_CALL_API = ''' _known_projects = [_read_project_name(d) for d in _find_project_dirs()]
149
+ system = (
150
+ _resolve_system(
151
+ boss_mode=getattr(cfg_loader.sentinel, "boss_mode", "standard"),
152
+ project_name=cfg_loader.sentinel.project_name or _read_project_name(Path(".")),
153
+ project_description=getattr(cfg_loader.sentinel, "project_description", ""),
154
+ other_project_names=_known_projects,
155
+ )'''
156
+
157
+ if OLD_SYSTEM_CALL_API not in boss:
158
+ print("ERROR: API system prompt call not found")
159
+ sys.exit(1)
160
+ boss = boss.replace(OLD_SYSTEM_CALL_API, NEW_SYSTEM_CALL_API, 1)
161
+ print("Step 3b OK: API-mode system prompt passes project identity")
162
+
163
+ # CLI fallback mode
164
+ OLD_SYSTEM_CALL_CLI = ''' _resolve_system(getattr(cfg_loader.sentinel, "boss_mode", "standard"))
165
+ + (f"\\nYou are speaking with: {user_name}'''
166
+
167
+ NEW_SYSTEM_CALL_CLI = ''' _resolve_system(
168
+ boss_mode=getattr(cfg_loader.sentinel, "boss_mode", "standard"),
169
+ project_name=cfg_loader.sentinel.project_name or _read_project_name(Path(".")),
170
+ project_description=getattr(cfg_loader.sentinel, "project_description", ""),
171
+ other_project_names=[_read_project_name(d) for d in _find_project_dirs()],
172
+ )
173
+ + (f"\\nYou are speaking with: {user_name}'''
174
+
175
+ if OLD_SYSTEM_CALL_CLI not in boss:
176
+ print("ERROR: CLI system prompt call not found")
177
+ sys.exit(1)
178
+ boss = boss.replace(OLD_SYSTEM_CALL_CLI, NEW_SYSTEM_CALL_CLI, 1)
179
+ print("Step 3c OK: CLI-mode system prompt passes project identity")
180
+
181
+ with open(f'{CODE}/sentinel_boss.py', 'w', encoding='utf-8') as f:
182
+ f.write(boss)
183
+ try:
184
+ ast.parse(boss)
185
+ print("Step 3 OK: sentinel_boss.py Syntax OK")
186
+ except SyntaxError as e:
187
+ lines = boss.splitlines()
188
+ print(f"SyntaxError line {e.lineno}: {e.msg}")
189
+ for i in range(max(0, e.lineno-4), min(len(lines), e.lineno+3)):
190
+ print(f" {i+1}: {lines[i]}")
191
+ sys.exit(1)
192
+
193
+ print("\nAll steps complete.")
194
+ print("Add to each project's sentinel.properties:")
195
+ print(" PROJECT_NAME=1881")
196
+ print(" PROJECT_DESCRIPTION=Norwegian directory services and telecom platform")
197
+ print(" SLACK_WORKSPACE_ID=T01234ABCD # Slack team_id for workspace verification")