@misterhuydo/sentinel 1.6.16 → 1.6.18
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/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.6.
|
|
1
|
+
__version__ = "1.6.18"
|
package/python/sentinel/main.py
CHANGED
|
@@ -61,6 +61,21 @@ def _project_lock(project_name: str) -> "asyncio.Lock":
|
|
|
61
61
|
return _project_locks[project_name]
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def _is_paused(store: "StateStore", project_name: str, repo_name: str = "") -> tuple[bool, str]:
|
|
65
|
+
"""Return (paused, reason_text). Checks project-level then optional repo-level pause."""
|
|
66
|
+
if project_name and store.is_project_paused(project_name):
|
|
67
|
+
row = store.get_pause("project", project_name) or {}
|
|
68
|
+
rsn = row.get("reason") or "(no reason given)"
|
|
69
|
+
by = row.get("paused_by") or "admin"
|
|
70
|
+
return True, f"project `{project_name}` is paused by <@{by}> — {rsn}"
|
|
71
|
+
if repo_name and store.is_repo_paused(repo_name):
|
|
72
|
+
row = store.get_pause("repo", repo_name) or {}
|
|
73
|
+
rsn = row.get("reason") or "(no reason given)"
|
|
74
|
+
by = row.get("paused_by") or "admin"
|
|
75
|
+
return True, f"repo `{repo_name}` is paused by <@{by}> — {rsn}"
|
|
76
|
+
return False, ""
|
|
77
|
+
|
|
78
|
+
|
|
64
79
|
def _notify_skip(sentinel: SentinelConfig, event: "IssueEvent", reason: str) -> None:
|
|
65
80
|
"""Tell the submitter (and channel) when an issue is skipped via dedupe.
|
|
66
81
|
|
|
@@ -234,6 +249,11 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
234
249
|
logger.debug("Error %s is dismissed — skipping", event.fingerprint)
|
|
235
250
|
return
|
|
236
251
|
|
|
252
|
+
# ── Project-level pause — silent skip; we'll re-detect on resume ────────────
|
|
253
|
+
if store.is_project_paused(sentinel.project_name or ""):
|
|
254
|
+
logger.debug("Error %s skipped — project paused", event.fingerprint[:8])
|
|
255
|
+
return
|
|
256
|
+
|
|
237
257
|
repo = route(event, cfg_loader.repos)
|
|
238
258
|
if not repo:
|
|
239
259
|
from .notify import slack_alert as _slack_alert
|
|
@@ -255,11 +275,30 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
255
275
|
return
|
|
256
276
|
|
|
257
277
|
auto_commit = resolve_auto_commit(repo, sentinel)
|
|
278
|
+
auto_release = resolve_auto_release(repo, sentinel)
|
|
279
|
+
|
|
280
|
+
# Health-driven fixes target a service that is *currently down*. Override
|
|
281
|
+
# AUTO_RELEASE=false so the fix actually deploys without waiting for a
|
|
282
|
+
# human `manage_release` confirmation — the service is already broken,
|
|
283
|
+
# the urgency is real. AUTO_COMMIT is left alone so projects with PR-only
|
|
284
|
+
# policies still get a PR (reviewer can fast-track it), but those that
|
|
285
|
+
# already use AUTO_COMMIT=true will trigger Jenkins immediately.
|
|
286
|
+
if (event.logger_name or "") == "health_checker" and not auto_release:
|
|
287
|
+
logger.info(
|
|
288
|
+
"Health-driven fix for %s: forcing AUTO_RELEASE=true (service is down — quick recovery needed)",
|
|
289
|
+
repo.repo_name,
|
|
290
|
+
)
|
|
291
|
+
auto_release = True
|
|
258
292
|
|
|
259
293
|
if Path("SENTINEL_PAUSE").exists():
|
|
260
294
|
logger.info("SENTINEL_PAUSE present — fix activity halted")
|
|
261
295
|
return
|
|
262
296
|
|
|
297
|
+
# ── Repo-level pause — silent skip; admin must resume to act on this ────────
|
|
298
|
+
if store.is_repo_paused(repo.repo_name):
|
|
299
|
+
logger.debug("Error %s skipped — repo %s paused", event.fingerprint[:8], repo.repo_name)
|
|
300
|
+
return
|
|
301
|
+
|
|
263
302
|
# ── Suppress repeat notifications for same error (notified within 4h) ───────
|
|
264
303
|
if store.was_notified_recently(event.fingerprint):
|
|
265
304
|
logger.debug("Error %s already notified recently — suppressing repeat", event.fingerprint[:8])
|
|
@@ -597,6 +636,14 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
597
636
|
logger.info("SENTINEL_PAUSE present -- fix activity halted")
|
|
598
637
|
return
|
|
599
638
|
|
|
639
|
+
# Project-level pause check (admin pause via Boss)
|
|
640
|
+
paused, prsn = _is_paused(store, sentinel.project_name or "")
|
|
641
|
+
if paused:
|
|
642
|
+
logger.info("Issue %s skipped — %s", event.source, prsn)
|
|
643
|
+
_notify_skip(sentinel, event, prsn)
|
|
644
|
+
mark_done(event.issue_file)
|
|
645
|
+
return
|
|
646
|
+
|
|
600
647
|
if store.fix_attempted_recently(event.fingerprint, hours=24):
|
|
601
648
|
logger.info(
|
|
602
649
|
"Issue %s skipped — fingerprint %s attempted in last 24h "
|
|
@@ -646,6 +693,14 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
646
693
|
mark_done(event.issue_file) # archive so it doesn't re-prompt every poll
|
|
647
694
|
return
|
|
648
695
|
|
|
696
|
+
# Repo-level pause check now that we know the target repo
|
|
697
|
+
paused, rrsn = _is_paused(store, "", repo.repo_name)
|
|
698
|
+
if paused:
|
|
699
|
+
logger.info("Issue %s skipped — %s", event.source, rrsn)
|
|
700
|
+
_notify_skip(sentinel, event, rrsn)
|
|
701
|
+
mark_done(event.issue_file)
|
|
702
|
+
return
|
|
703
|
+
|
|
649
704
|
# Per-project lock — serialise issue processing within a project so two
|
|
650
705
|
# concurrent claude sessions never race on the working tree of any repo.
|
|
651
706
|
async with _project_lock(sentinel.project_name or "_default"):
|
|
@@ -1126,15 +1181,32 @@ async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
|
|
|
1126
1181
|
cfg_loader.sentinel.workspace_dir, store=store,
|
|
1127
1182
|
)
|
|
1128
1183
|
)
|
|
1184
|
+
# If the project is paused, skip ALL health-checker actions silently —
|
|
1185
|
+
# we'll re-detect anything still wrong on resume.
|
|
1186
|
+
_project_paused_now = store.is_project_paused(cfg_loader.sentinel.project_name or "")
|
|
1129
1187
|
for hr in health_results:
|
|
1188
|
+
if _project_paused_now:
|
|
1189
|
+
continue
|
|
1190
|
+
# Repo-level pause: don't auto-fix or alert_once for paused repos
|
|
1191
|
+
# (recovered notifications still fire — they're just informational).
|
|
1192
|
+
if hr["action"] in ("fix", "alert_once") and store.is_repo_paused(hr["repo_name"]):
|
|
1193
|
+
logger.debug(
|
|
1194
|
+
"health_checker: %s action=%s skipped — repo paused",
|
|
1195
|
+
hr["repo_name"], hr["action"],
|
|
1196
|
+
)
|
|
1197
|
+
continue
|
|
1130
1198
|
if hr["action"] == "fix":
|
|
1131
1199
|
fp = f"health-{hr['repo_name']}"
|
|
1132
1200
|
store.record_error(fp, f"health_checker/{hr['repo_name']}", hr["message"])
|
|
1133
1201
|
if not store.fix_attempted_recently(fp, hours=6):
|
|
1134
1202
|
from .log_parser import ErrorEvent as _EE
|
|
1135
1203
|
from datetime import datetime, timezone as _tz
|
|
1204
|
+
# Use the repo name as the source so repo_router.route()
|
|
1205
|
+
# can map directly to the affected repo. The original
|
|
1206
|
+
# "health_checker/<repo>" provenance is preserved in
|
|
1207
|
+
# thread/logger_name and in the errors table audit row above.
|
|
1136
1208
|
synth = _EE(
|
|
1137
|
-
source=
|
|
1209
|
+
source=hr["repo_name"],
|
|
1138
1210
|
log_file="",
|
|
1139
1211
|
timestamp=datetime.now(_tz.utc).isoformat(),
|
|
1140
1212
|
level="ERROR",
|
|
@@ -1998,6 +1998,84 @@ _TOOLS = [
|
|
|
1998
1998
|
"required": ["repo_name"],
|
|
1999
1999
|
},
|
|
2000
2000
|
},
|
|
2001
|
+
{
|
|
2002
|
+
"name": "pause_project",
|
|
2003
|
+
"description": (
|
|
2004
|
+
"Halt ALL Sentinel fix activity for the current project until resume_project is called. "
|
|
2005
|
+
"Stops auto-fixes from logs, health-check auto-fixes, and Boss-submitted issues. Recovery "
|
|
2006
|
+
"notifications still fire. Admin-only. "
|
|
2007
|
+
"Use when: 'pause sentinel for elprint', 'stop working on this project', 'we're doing manual deploys today'."
|
|
2008
|
+
),
|
|
2009
|
+
"input_schema": {
|
|
2010
|
+
"type": "object",
|
|
2011
|
+
"properties": {
|
|
2012
|
+
"reason": {
|
|
2013
|
+
"type": "string",
|
|
2014
|
+
"description": "Optional reason — shown to anyone whose issue gets skipped while paused.",
|
|
2015
|
+
},
|
|
2016
|
+
},
|
|
2017
|
+
"required": [],
|
|
2018
|
+
},
|
|
2019
|
+
},
|
|
2020
|
+
{
|
|
2021
|
+
"name": "resume_project",
|
|
2022
|
+
"description": (
|
|
2023
|
+
"Lift a project pause set by pause_project. Sentinel resumes auto-fixing on the next poll. "
|
|
2024
|
+
"Admin-only. Use when: 'resume sentinel', 'unpause this project', 'we're back online'."
|
|
2025
|
+
),
|
|
2026
|
+
"input_schema": {
|
|
2027
|
+
"type": "object",
|
|
2028
|
+
"properties": {},
|
|
2029
|
+
"required": [],
|
|
2030
|
+
},
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
"name": "pause_repo",
|
|
2034
|
+
"description": (
|
|
2035
|
+
"Halt Sentinel fix activity for ONE repo. Other repos in the project keep working normally. "
|
|
2036
|
+
"Useful when one service is being manually rebuilt or its build is broken in CI. Admin-only. "
|
|
2037
|
+
"Use when: 'pause elprint-reporting-service', 'stop working on repo X for now'."
|
|
2038
|
+
),
|
|
2039
|
+
"input_schema": {
|
|
2040
|
+
"type": "object",
|
|
2041
|
+
"properties": {
|
|
2042
|
+
"repo_name": {
|
|
2043
|
+
"type": "string",
|
|
2044
|
+
"description": "Repo name as configured (from repo-configs/*.properties).",
|
|
2045
|
+
},
|
|
2046
|
+
"reason": {
|
|
2047
|
+
"type": "string",
|
|
2048
|
+
"description": "Optional reason — shown to anyone whose issue gets skipped while paused.",
|
|
2049
|
+
},
|
|
2050
|
+
},
|
|
2051
|
+
"required": ["repo_name"],
|
|
2052
|
+
},
|
|
2053
|
+
},
|
|
2054
|
+
{
|
|
2055
|
+
"name": "resume_repo",
|
|
2056
|
+
"description": (
|
|
2057
|
+
"Lift a repo pause set by pause_repo. Admin-only. "
|
|
2058
|
+
"Use when: 'resume elprint-reporting-service', 'unpause repo X'."
|
|
2059
|
+
),
|
|
2060
|
+
"input_schema": {
|
|
2061
|
+
"type": "object",
|
|
2062
|
+
"properties": {
|
|
2063
|
+
"repo_name": {
|
|
2064
|
+
"type": "string",
|
|
2065
|
+
"description": "Repo name to resume.",
|
|
2066
|
+
},
|
|
2067
|
+
},
|
|
2068
|
+
"required": ["repo_name"],
|
|
2069
|
+
},
|
|
2070
|
+
},
|
|
2071
|
+
{
|
|
2072
|
+
"name": "list_paused",
|
|
2073
|
+
"description": (
|
|
2074
|
+
"List all current pauses (project-level and per-repo) with who paused them and why. "
|
|
2075
|
+
"Anyone in the allowed list can run this."
|
|
2076
|
+
),
|
|
2077
|
+
"input_schema": {"type": "object", "properties": {}, "required": []},
|
|
2078
|
+
},
|
|
2001
2079
|
{
|
|
2002
2080
|
"name": "chain_release",
|
|
2003
2081
|
"description": (
|
|
@@ -2285,6 +2363,28 @@ def _format_duration(seconds: int) -> str:
|
|
|
2285
2363
|
# ── Tool execution ────────────────────────────────────────────────────────────
|
|
2286
2364
|
|
|
2287
2365
|
async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None, user_id: str = "", channel: str = "", is_admin: bool = False) -> str:
|
|
2366
|
+
if name == "list_paused":
|
|
2367
|
+
pauses = store.list_pauses()
|
|
2368
|
+
if not pauses:
|
|
2369
|
+
return json.dumps({
|
|
2370
|
+
"total": 0,
|
|
2371
|
+
"pauses": [],
|
|
2372
|
+
"message": "No active pauses. Sentinel is fully operational.",
|
|
2373
|
+
})
|
|
2374
|
+
return json.dumps({
|
|
2375
|
+
"total": len(pauses),
|
|
2376
|
+
"pauses": [
|
|
2377
|
+
{
|
|
2378
|
+
"scope": p["scope_type"], # 'project' or 'repo'
|
|
2379
|
+
"name": p["scope_value"],
|
|
2380
|
+
"paused_by": p.get("paused_by") or "",
|
|
2381
|
+
"paused_at": (p.get("paused_at") or "")[:19],
|
|
2382
|
+
"reason": p.get("reason") or "",
|
|
2383
|
+
}
|
|
2384
|
+
for p in pauses
|
|
2385
|
+
],
|
|
2386
|
+
})
|
|
2387
|
+
|
|
2288
2388
|
if name == "get_status":
|
|
2289
2389
|
hours = int(inputs.get("hours", 24))
|
|
2290
2390
|
errors = store.get_recent_errors(hours)
|
|
@@ -4142,7 +4242,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
4142
4242
|
return json.dumps({"error": "cannot determine user — not clearing"})
|
|
4143
4243
|
|
|
4144
4244
|
# ── Admin-only tools ──────────────────────────────────────────────────────
|
|
4145
|
-
_ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr", "install_tool", "manage_release", "chain_release"}
|
|
4245
|
+
_ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr", "install_tool", "manage_release", "chain_release", "pause_project", "resume_project", "pause_repo", "resume_repo"}
|
|
4146
4246
|
if name in _ADMIN_TOOLS:
|
|
4147
4247
|
if not is_admin:
|
|
4148
4248
|
return json.dumps({"error": "This operation is admin-only (SLACK_ADMIN_USERS). Contact a Sentinel admin if you need access."})
|
|
@@ -4237,6 +4337,71 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
4237
4337
|
),
|
|
4238
4338
|
})
|
|
4239
4339
|
|
|
4340
|
+
if name == "pause_project":
|
|
4341
|
+
project_name = cfg_loader.sentinel.project_name or "_default"
|
|
4342
|
+
reason = (inputs.get("reason") or "").strip()
|
|
4343
|
+
store.pause_project(project_name, paused_by=user_id, reason=reason)
|
|
4344
|
+
logger.info("Boss: project %s paused by %s (reason: %s)", project_name, user_id, reason or "none")
|
|
4345
|
+
return json.dumps({
|
|
4346
|
+
"status": "paused",
|
|
4347
|
+
"project": project_name,
|
|
4348
|
+
"reason": reason or None,
|
|
4349
|
+
"message": (
|
|
4350
|
+
f"Project `{project_name}` is paused. Sentinel will skip all auto-fixes and Boss-submitted "
|
|
4351
|
+
f"issues until `resume_project` is called. Recovery notifications still fire."
|
|
4352
|
+
),
|
|
4353
|
+
})
|
|
4354
|
+
|
|
4355
|
+
if name == "resume_project":
|
|
4356
|
+
project_name = cfg_loader.sentinel.project_name or "_default"
|
|
4357
|
+
existed = store.resume_project(project_name)
|
|
4358
|
+
logger.info("Boss: project %s resume by %s (was_paused=%s)", project_name, user_id, existed)
|
|
4359
|
+
return json.dumps({
|
|
4360
|
+
"status": "resumed" if existed else "not_paused",
|
|
4361
|
+
"project": project_name,
|
|
4362
|
+
"message": (
|
|
4363
|
+
f"Project `{project_name}` is back online — Sentinel will resume on the next poll."
|
|
4364
|
+
if existed else
|
|
4365
|
+
f"Project `{project_name}` was not paused — nothing to do."
|
|
4366
|
+
),
|
|
4367
|
+
})
|
|
4368
|
+
|
|
4369
|
+
if name == "pause_repo":
|
|
4370
|
+
repo_name = (inputs.get("repo_name") or "").strip()
|
|
4371
|
+
reason = (inputs.get("reason") or "").strip()
|
|
4372
|
+
if not repo_name:
|
|
4373
|
+
return json.dumps({"error": "repo_name is required"})
|
|
4374
|
+
if repo_name not in cfg_loader.repos:
|
|
4375
|
+
return json.dumps({"error": f"Unknown repo `{repo_name}`. Configured repos: "
|
|
4376
|
+
f"{', '.join(sorted(cfg_loader.repos.keys()))}"})
|
|
4377
|
+
store.pause_repo(repo_name, paused_by=user_id, reason=reason)
|
|
4378
|
+
logger.info("Boss: repo %s paused by %s (reason: %s)", repo_name, user_id, reason or "none")
|
|
4379
|
+
return json.dumps({
|
|
4380
|
+
"status": "paused",
|
|
4381
|
+
"repo": repo_name,
|
|
4382
|
+
"reason": reason or None,
|
|
4383
|
+
"message": (
|
|
4384
|
+
f"Repo `{repo_name}` is paused. Other repos keep working normally. "
|
|
4385
|
+
f"Use `resume_repo` to lift the pause."
|
|
4386
|
+
),
|
|
4387
|
+
})
|
|
4388
|
+
|
|
4389
|
+
if name == "resume_repo":
|
|
4390
|
+
repo_name = (inputs.get("repo_name") or "").strip()
|
|
4391
|
+
if not repo_name:
|
|
4392
|
+
return json.dumps({"error": "repo_name is required"})
|
|
4393
|
+
existed = store.resume_repo(repo_name)
|
|
4394
|
+
logger.info("Boss: repo %s resume by %s (was_paused=%s)", repo_name, user_id, existed)
|
|
4395
|
+
return json.dumps({
|
|
4396
|
+
"status": "resumed" if existed else "not_paused",
|
|
4397
|
+
"repo": repo_name,
|
|
4398
|
+
"message": (
|
|
4399
|
+
f"Repo `{repo_name}` is back online."
|
|
4400
|
+
if existed else
|
|
4401
|
+
f"Repo `{repo_name}` was not paused — nothing to do."
|
|
4402
|
+
),
|
|
4403
|
+
})
|
|
4404
|
+
|
|
4240
4405
|
if name == "reset_fingerprint":
|
|
4241
4406
|
fp = inputs.get("fingerprint", "").strip()
|
|
4242
4407
|
if not fp:
|
|
@@ -111,6 +111,18 @@ class StateStore:
|
|
|
111
111
|
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
112
112
|
turn_count INTEGER NOT NULL DEFAULT 0
|
|
113
113
|
);
|
|
114
|
+
|
|
115
|
+
-- Admin-controlled pauses. scope_type is 'project' or 'repo';
|
|
116
|
+
-- scope_value is the project name or the repo name. While a row
|
|
117
|
+
-- exists, fix activity for that scope is halted.
|
|
118
|
+
CREATE TABLE IF NOT EXISTS pauses (
|
|
119
|
+
scope_type TEXT NOT NULL CHECK(scope_type IN ('project','repo')),
|
|
120
|
+
scope_value TEXT NOT NULL,
|
|
121
|
+
paused_by TEXT,
|
|
122
|
+
paused_at TEXT NOT NULL,
|
|
123
|
+
reason TEXT,
|
|
124
|
+
PRIMARY KEY (scope_type, scope_value)
|
|
125
|
+
);
|
|
114
126
|
""")
|
|
115
127
|
self._migrate()
|
|
116
128
|
logger.debug("StateStore initialised at %s", self.db_path)
|
|
@@ -942,6 +954,75 @@ class StateStore:
|
|
|
942
954
|
).fetchall()
|
|
943
955
|
return [dict(r) for r in rows]
|
|
944
956
|
|
|
957
|
+
# -- Admin pauses (project-wide and per-repo) ----------------------------------
|
|
958
|
+
|
|
959
|
+
def _set_pause(self, scope_type: str, scope_value: str, paused_by: str, reason: str) -> None:
|
|
960
|
+
if scope_type not in ("project", "repo"):
|
|
961
|
+
raise ValueError(f"scope_type must be 'project' or 'repo', got {scope_type!r}")
|
|
962
|
+
with self._conn() as conn:
|
|
963
|
+
conn.execute(
|
|
964
|
+
"INSERT OR REPLACE INTO pauses (scope_type, scope_value, paused_by, paused_at, reason) "
|
|
965
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
966
|
+
(scope_type, scope_value, paused_by, _now(), reason),
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
def _clear_pause(self, scope_type: str, scope_value: str) -> bool:
|
|
970
|
+
with self._conn() as conn:
|
|
971
|
+
cur = conn.execute(
|
|
972
|
+
"DELETE FROM pauses WHERE scope_type=? AND scope_value=?",
|
|
973
|
+
(scope_type, scope_value),
|
|
974
|
+
)
|
|
975
|
+
return cur.rowcount > 0
|
|
976
|
+
|
|
977
|
+
def pause_project(self, project_name: str, paused_by: str = "", reason: str = "") -> None:
|
|
978
|
+
"""Halt all fix activity for the named project until resume_project is called."""
|
|
979
|
+
self._set_pause("project", project_name, paused_by, reason)
|
|
980
|
+
|
|
981
|
+
def resume_project(self, project_name: str) -> bool:
|
|
982
|
+
"""Lift project pause. Returns True if a pause existed, False if no-op."""
|
|
983
|
+
return self._clear_pause("project", project_name)
|
|
984
|
+
|
|
985
|
+
def is_project_paused(self, project_name: str) -> bool:
|
|
986
|
+
with self._conn() as conn:
|
|
987
|
+
row = conn.execute(
|
|
988
|
+
"SELECT 1 FROM pauses WHERE scope_type='project' AND scope_value=?",
|
|
989
|
+
(project_name,),
|
|
990
|
+
).fetchone()
|
|
991
|
+
return row is not None
|
|
992
|
+
|
|
993
|
+
def pause_repo(self, repo_name: str, paused_by: str = "", reason: str = "") -> None:
|
|
994
|
+
"""Halt fix activity for one repo. Other repos in the project keep working."""
|
|
995
|
+
self._set_pause("repo", repo_name, paused_by, reason)
|
|
996
|
+
|
|
997
|
+
def resume_repo(self, repo_name: str) -> bool:
|
|
998
|
+
return self._clear_pause("repo", repo_name)
|
|
999
|
+
|
|
1000
|
+
def is_repo_paused(self, repo_name: str) -> bool:
|
|
1001
|
+
with self._conn() as conn:
|
|
1002
|
+
row = conn.execute(
|
|
1003
|
+
"SELECT 1 FROM pauses WHERE scope_type='repo' AND scope_value=?",
|
|
1004
|
+
(repo_name,),
|
|
1005
|
+
).fetchone()
|
|
1006
|
+
return row is not None
|
|
1007
|
+
|
|
1008
|
+
def get_pause(self, scope_type: str, scope_value: str) -> dict | None:
|
|
1009
|
+
"""Return the pause row (paused_by, paused_at, reason) or None."""
|
|
1010
|
+
with self._conn() as conn:
|
|
1011
|
+
row = conn.execute(
|
|
1012
|
+
"SELECT * FROM pauses WHERE scope_type=? AND scope_value=?",
|
|
1013
|
+
(scope_type, scope_value),
|
|
1014
|
+
).fetchone()
|
|
1015
|
+
return dict(row) if row else None
|
|
1016
|
+
|
|
1017
|
+
def list_pauses(self) -> list[dict]:
|
|
1018
|
+
"""Return all active pauses (project + repo) ordered by paused_at DESC."""
|
|
1019
|
+
with self._conn() as conn:
|
|
1020
|
+
rows = conn.execute(
|
|
1021
|
+
"SELECT scope_type, scope_value, paused_by, paused_at, reason "
|
|
1022
|
+
"FROM pauses ORDER BY paused_at DESC"
|
|
1023
|
+
).fetchall()
|
|
1024
|
+
return [dict(r) for r in rows]
|
|
1025
|
+
|
|
945
1026
|
def get_all_user_stats(self) -> list[dict]:
|
|
946
1027
|
"""Return activity summary for every known Slack user."""
|
|
947
1028
|
users = self.get_all_users() # {user_id: display_name}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Unit tests for project + repo pause API on StateStore."""
|
|
2
|
+
import pytest
|
|
3
|
+
from sentinel.state_store import StateStore
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def store(tmp_path):
|
|
8
|
+
return StateStore(str(tmp_path / "pauses.db"))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── project pause ────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def test_project_pause_default_unpaused(store):
|
|
14
|
+
assert store.is_project_paused("elprint") is False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_pause_project_then_check(store):
|
|
18
|
+
store.pause_project("elprint", paused_by="U123", reason="manual deploy")
|
|
19
|
+
assert store.is_project_paused("elprint") is True
|
|
20
|
+
row = store.get_pause("project", "elprint")
|
|
21
|
+
assert row["paused_by"] == "U123"
|
|
22
|
+
assert row["reason"] == "manual deploy"
|
|
23
|
+
assert row["paused_at"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_resume_project_clears_pause(store):
|
|
27
|
+
store.pause_project("elprint", paused_by="U1", reason="x")
|
|
28
|
+
assert store.resume_project("elprint") is True
|
|
29
|
+
assert store.is_project_paused("elprint") is False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_resume_project_returns_false_when_not_paused(store):
|
|
33
|
+
assert store.resume_project("elprint") is False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_pause_project_is_idempotent(store):
|
|
37
|
+
store.pause_project("elprint", paused_by="U1", reason="first")
|
|
38
|
+
store.pause_project("elprint", paused_by="U2", reason="second")
|
|
39
|
+
row = store.get_pause("project", "elprint")
|
|
40
|
+
assert row["paused_by"] == "U2"
|
|
41
|
+
assert row["reason"] == "second"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── repo pause ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def test_repo_pause_independent_of_project(store):
|
|
47
|
+
store.pause_repo("elprint-reporting-service", paused_by="U1", reason="broken build")
|
|
48
|
+
assert store.is_repo_paused("elprint-reporting-service") is True
|
|
49
|
+
assert store.is_project_paused("elprint") is False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_pause_one_repo_doesnt_affect_others(store):
|
|
53
|
+
store.pause_repo("elprint-reporting-service", paused_by="U1", reason="x")
|
|
54
|
+
assert store.is_repo_paused("elprint-reporting-service") is True
|
|
55
|
+
assert store.is_repo_paused("elprint-component-service") is False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_resume_repo_clears_pause(store):
|
|
59
|
+
store.pause_repo("foo", paused_by="U1", reason="x")
|
|
60
|
+
assert store.resume_repo("foo") is True
|
|
61
|
+
assert store.is_repo_paused("foo") is False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── list_pauses ──────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def test_list_pauses_empty(store):
|
|
67
|
+
assert store.list_pauses() == []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_list_pauses_combined(store):
|
|
71
|
+
store.pause_project("elprint", paused_by="U1", reason="r1")
|
|
72
|
+
store.pause_repo("elprint-reporting-service", paused_by="U2", reason="r2")
|
|
73
|
+
pauses = store.list_pauses()
|
|
74
|
+
assert len(pauses) == 2
|
|
75
|
+
scopes = {p["scope_type"] for p in pauses}
|
|
76
|
+
assert scopes == {"project", "repo"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_invalid_scope_type_rejected(store):
|
|
80
|
+
with pytest.raises(ValueError):
|
|
81
|
+
store._set_pause("BOGUS", "x", "U1", "r")
|