Skip to content

Commit a271f52

Browse files
hummbl-devClaude (agent)claude
authored
feat(webhooks): webhook notification module for score delivery (#58)
* feat(webhooks): add webhook notification module for score delivery Send score results to Slack, Discord, or generic webhook endpoints. Supports HMAC-SHA256 signing, auto-format detection from URL, and a webhook-test subcommand for connectivity verification. - New src/arbiter/webhooks.py with WebhookPayload, format_slack, format_discord, send_webhook (stdlib urllib, 10s timeout) - --webhook/--webhook-secret flags on score, score-url, certify - webhook-test subcommand with --dry-run and --format options - 29 tests covering serialization, signing, formatting, send/fail Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): use relative path in webhook CLI test --------- Co-authored-by: Claude (agent) <claude@agents.hummbl.io> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a0f2da7 commit a271f52

3 files changed

Lines changed: 562 additions & 0 deletions

File tree

src/arbiter/__main__.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@
5252
_SEVERITY_RANK = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}
5353

5454

55+
def _maybe_send_webhook(args: argparse.Namespace, payload_kwargs: dict) -> None:
56+
"""If --webhook is set, build a WebhookPayload and send it."""
57+
webhook_url = getattr(args, "webhook", None)
58+
if not webhook_url:
59+
return
60+
from arbiter.webhooks import WebhookPayload, send_webhook
61+
secret = getattr(args, "webhook_secret", "") or ""
62+
payload = WebhookPayload(**payload_kwargs)
63+
ok = send_webhook(webhook_url, payload, secret=secret)
64+
if ok:
65+
print("Webhook delivered successfully.", file=sys.stderr)
66+
else:
67+
print("WARNING: Webhook delivery failed.", file=sys.stderr)
68+
69+
5570
def _apply_noise_filter(findings: list[Finding], threshold: int | None) -> list[Finding]:
5671
"""Apply noise filter if threshold is set. Prints summary and returns filtered findings."""
5772
if threshold is None:
@@ -299,6 +314,16 @@ def cmd_score(args: argparse.Namespace) -> None:
299314
print(f"Score: n/a ({score.grade}) — no scorable Python LOC | Findings: {score.total_findings} | LOC: {loc:,}")
300315
_print_footer()
301316

317+
_maybe_send_webhook(args, {
318+
"repo": repo_path.name,
319+
"score": score.overall if score.is_scorable else 0.0,
320+
"grade": score.grade,
321+
"findings": score.total_findings,
322+
"loc": loc,
323+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
324+
"dimensions": {"lint": score.lint_score, "security": score.security_score, "complexity": score.complexity_score},
325+
})
326+
302327
if cfg.fail_under is not None and score.is_scorable and score.overall < cfg.fail_under:
303328
print(f"FAIL: Score {score.overall:.1f} is below threshold {cfg.fail_under}", file=sys.stderr)
304329
sys.exit(1)
@@ -435,6 +460,16 @@ def cmd_certify(args: argparse.Namespace) -> None:
435460
)
436461
print(f"Recorded in audit trail: {args.trail}", file=sys.stderr)
437462

463+
_maybe_send_webhook(args, {
464+
"repo": repo_path.name,
465+
"score": result.overall,
466+
"grade": result.decision,
467+
"findings": result.findings_count,
468+
"loc": loc,
469+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
470+
"dimensions": {"code": result.code_score, "governance": result.governance_score, "dependencies": result.dep_score},
471+
})
472+
438473

439474
def cmd_audit_trail(args: argparse.Namespace) -> None:
440475
"""Manage the VERUM-aligned audit trail."""
@@ -1459,6 +1494,17 @@ def cmd_score_url(args: argparse.Namespace) -> None:
14591494
print(f"Score: n/a ({score.grade}) — no scorable Python LOC | Findings: {score.total_findings} | LOC: {loc:,}")
14601495
_print_footer()
14611496

1497+
_maybe_send_webhook(args, {
1498+
"repo": repo_name,
1499+
"score": score.overall if score.is_scorable else 0.0,
1500+
"grade": score.grade,
1501+
"findings": score.total_findings,
1502+
"loc": loc,
1503+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1504+
"dimensions": {"lint": score.lint_score, "security": score.security_score, "complexity": score.complexity_score},
1505+
"url": args.url,
1506+
})
1507+
14621508
if cfg.fail_under is not None and score.is_scorable and score.overall < cfg.fail_under:
14631509
print(f"FAIL: Score {score.overall:.1f} is below threshold {cfg.fail_under}", file=sys.stderr)
14641510
sys.exit(1)
@@ -1997,6 +2043,41 @@ def cmd_compliance(args: argparse.Namespace) -> None:
19972043
print(report)
19982044

19992045

2046+
def cmd_webhook_test(args: argparse.Namespace) -> None:
2047+
"""Send a test webhook payload to verify connectivity."""
2048+
from arbiter.webhooks import WebhookPayload, detect_format, format_slack, format_discord, send_webhook
2049+
2050+
fmt = args.format or detect_format(args.url)
2051+
payload = WebhookPayload(
2052+
repo="arbiter-test",
2053+
score=85.0,
2054+
grade="B+",
2055+
findings=12,
2056+
loc=3500,
2057+
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
2058+
dimensions={"lint": 90.0, "security": 85.0, "complexity": 80.0},
2059+
url="https://github.com/arbiter/test",
2060+
)
2061+
2062+
if args.dry_run:
2063+
if fmt == "slack":
2064+
data = format_slack(payload)
2065+
elif fmt == "discord":
2066+
data = format_discord(payload)
2067+
else:
2068+
data = payload.to_dict()
2069+
print(json.dumps(data, indent=2))
2070+
return
2071+
2072+
secret = args.secret or ""
2073+
ok = send_webhook(args.url, payload, secret=secret)
2074+
if ok:
2075+
print(f"Test webhook delivered to {fmt} endpoint.")
2076+
else:
2077+
print(f"FAILED: Could not deliver test webhook to {args.url}", file=sys.stderr)
2078+
sys.exit(1)
2079+
2080+
20002081
def main() -> None:
20012082
parser = argparse.ArgumentParser(description="Arbiter -- Agent-aware code quality system")
20022083
parser.add_argument("--db", help="Path to SQLite database (default: arbiter_data.db)")
@@ -2018,6 +2099,8 @@ def main() -> None:
20182099
p_score.add_argument("--fail-under", type=float, default=None, help="Exit non-zero if score is below this threshold")
20192100
p_score.add_argument("--noise-threshold", type=int, default=None, help="Cap findings per rule_id at this number (default: disabled)")
20202101
p_score.add_argument("--profile", choices=list_profiles(), help="Scoring profile preset (default, enterprise, startup, oss, strict)")
2102+
p_score.add_argument("--webhook", type=str, default=None, help="Webhook URL to POST score results to")
2103+
p_score.add_argument("--webhook-secret", type=str, default="", help="HMAC-SHA256 secret for webhook signing")
20212104

20222105
# compare
20232106
p_compare = subparsers.add_parser("compare", help="Score two repos side-by-side and compare")
@@ -2037,6 +2120,8 @@ def main() -> None:
20372120
p_score_url.add_argument("--no-cleanup", action="store_true", help="Keep cloned repo after scoring")
20382121
p_score_url.add_argument("--noise-threshold", type=int, default=None, help="Cap findings per rule_id at this number (default: disabled)")
20392122
p_score_url.add_argument("--profile", choices=list_profiles(), help="Scoring profile preset (default, enterprise, startup, oss, strict)")
2123+
p_score_url.add_argument("--webhook", type=str, default=None, help="Webhook URL to POST score results to")
2124+
p_score_url.add_argument("--webhook-secret", type=str, default="", help="HMAC-SHA256 secret for webhook signing")
20402125

20412126
# diff
20422127
p_diff = subparsers.add_parser("diff", help="Score only files changed since base branch")
@@ -2079,6 +2164,8 @@ def main() -> None:
20792164
p_certify.add_argument("--trail", default="arbiter_audit.jsonl", help="Audit trail path")
20802165
p_certify.add_argument("--no-audit", action="store_true", help="Skip audit trail recording")
20812166
p_certify.add_argument("--fail-on-failed", action="store_true", help="Exit non-zero if FAILED")
2167+
p_certify.add_argument("--webhook", type=str, default=None, help="Webhook URL to POST certification results to")
2168+
p_certify.add_argument("--webhook-secret", type=str, default="", help="HMAC-SHA256 secret for webhook signing")
20822169

20832170
# audit-trail
20842171
p_audit = subparsers.add_parser("audit-trail",
@@ -2304,6 +2391,14 @@ def main() -> None:
23042391
p_compliance.add_argument("--noise-threshold", type=int, default=None, help="Suppress rules with more findings than this")
23052392
p_compliance.add_argument("--profile", help=f"Scoring profile ({', '.join(SCORING_PROFILES)})")
23062393

2394+
# webhook-test
2395+
p_wh = subparsers.add_parser("webhook-test", help="Send a test webhook payload to verify connectivity")
2396+
p_wh.add_argument("url", help="Webhook URL to test")
2397+
p_wh.add_argument("--format", choices=["slack", "discord", "generic"], default=None,
2398+
help="Force output format (auto-detected from URL if omitted)")
2399+
p_wh.add_argument("--secret", type=str, default="", help="HMAC-SHA256 secret for signing")
2400+
p_wh.add_argument("--dry-run", action="store_true", help="Print formatted payload without sending")
2401+
23072402
args = parser.parse_args()
23082403

23092404
commands = {
@@ -2345,6 +2440,7 @@ def main() -> None:
23452440
"profiles": cmd_profiles,
23462441
"team": cmd_team,
23472442
"compliance": cmd_compliance,
2443+
"webhook-test": cmd_webhook_test,
23482444
}
23492445

23502446
handler = commands.get(args.command)

src/arbiter/webhooks.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Webhook notification module — send score results to external services.
2+
3+
Supports Slack (Block Kit) and Discord (embeds) with auto-detection from URL.
4+
Optional HMAC-SHA256 signing for payload verification.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import hashlib
10+
import hmac
11+
import json
12+
import urllib.request
13+
import urllib.error
14+
from dataclasses import asdict, dataclass, field
15+
from datetime import datetime, timezone
16+
17+
18+
@dataclass
19+
class WebhookPayload:
20+
"""Score result payload for webhook delivery."""
21+
22+
repo: str
23+
score: float
24+
grade: str
25+
findings: int
26+
loc: int
27+
timestamp: str
28+
dimensions: dict = field(default_factory=dict)
29+
url: str = ""
30+
31+
def to_json(self) -> str:
32+
"""Serialize to JSON string."""
33+
return json.dumps(asdict(self), indent=2)
34+
35+
def to_dict(self) -> dict:
36+
"""Serialize to plain dict."""
37+
return asdict(self)
38+
39+
40+
def _grade_emoji(grade: str) -> str:
41+
"""Map letter grade to emoji for chat formatting."""
42+
return {
43+
"A+": "\u2b50", "A": "\u2705", "A-": "\u2705",
44+
"B+": "\U0001f7e2", "B": "\U0001f7e2", "B-": "\U0001f7e1",
45+
"C+": "\U0001f7e1", "C": "\U0001f7e0", "C-": "\U0001f7e0",
46+
"D": "\U0001f534", "F": "\u274c", "N/A": "\u2753",
47+
}.get(grade, "\u2753")
48+
49+
50+
def _grade_color(grade: str) -> int:
51+
"""Map letter grade to Discord embed color (decimal)."""
52+
if grade.startswith("A"):
53+
return 0x2ECC71 # green
54+
if grade.startswith("B"):
55+
return 0x3498DB # blue
56+
if grade.startswith("C"):
57+
return 0xF39C12 # orange
58+
return 0xE74C3C # red
59+
60+
61+
def format_slack(payload: WebhookPayload) -> dict:
62+
"""Format payload as Slack Block Kit message."""
63+
emoji = _grade_emoji(payload.grade)
64+
dims = payload.dimensions
65+
dim_parts = [f"*{k.title()}*: {v}" for k, v in dims.items()] if dims else []
66+
dim_text = " | ".join(dim_parts) if dim_parts else "No dimension breakdown"
67+
68+
blocks = [
69+
{
70+
"type": "header",
71+
"text": {
72+
"type": "plain_text",
73+
"text": f"{emoji} Arbiter Score: {payload.repo}",
74+
},
75+
},
76+
{
77+
"type": "section",
78+
"fields": [
79+
{"type": "mrkdwn", "text": f"*Score:* {payload.score:.1f}"},
80+
{"type": "mrkdwn", "text": f"*Grade:* {payload.grade}"},
81+
{"type": "mrkdwn", "text": f"*Findings:* {payload.findings:,}"},
82+
{"type": "mrkdwn", "text": f"*LOC:* {payload.loc:,}"},
83+
],
84+
},
85+
{
86+
"type": "section",
87+
"text": {"type": "mrkdwn", "text": dim_text},
88+
},
89+
{
90+
"type": "context",
91+
"elements": [
92+
{"type": "mrkdwn", "text": f"Scored at {payload.timestamp} by Arbiter"},
93+
],
94+
},
95+
]
96+
97+
if payload.url:
98+
blocks.insert(
99+
-1,
100+
{
101+
"type": "section",
102+
"text": {"type": "mrkdwn", "text": f"<{payload.url}|View Repository>"},
103+
},
104+
)
105+
106+
return {"blocks": blocks}
107+
108+
109+
def format_discord(payload: WebhookPayload) -> dict:
110+
"""Format payload as Discord embed."""
111+
emoji = _grade_emoji(payload.grade)
112+
color = _grade_color(payload.grade)
113+
114+
fields = [
115+
{"name": "Score", "value": f"{payload.score:.1f}", "inline": True},
116+
{"name": "Grade", "value": f"{emoji} {payload.grade}", "inline": True},
117+
{"name": "Findings", "value": f"{payload.findings:,}", "inline": True},
118+
{"name": "LOC", "value": f"{payload.loc:,}", "inline": True},
119+
]
120+
121+
for k, v in payload.dimensions.items():
122+
fields.append({"name": k.title(), "value": str(v), "inline": True})
123+
124+
embed: dict = {
125+
"title": f"Arbiter Score: {payload.repo}",
126+
"color": color,
127+
"fields": fields,
128+
"footer": {"text": f"Scored at {payload.timestamp} by Arbiter"},
129+
}
130+
131+
if payload.url:
132+
embed["url"] = payload.url
133+
134+
return {"embeds": [embed]}
135+
136+
137+
def detect_format(url: str) -> str:
138+
"""Auto-detect webhook service from URL pattern.
139+
140+
Returns 'slack', 'discord', or 'generic'.
141+
"""
142+
if "hooks.slack.com" in url or "slack.com/api" in url:
143+
return "slack"
144+
if "discord.com/api/webhooks" in url or "discordapp.com/api/webhooks" in url:
145+
return "discord"
146+
return "generic"
147+
148+
149+
def _sign_payload(body: bytes, secret: str) -> str:
150+
"""Compute HMAC-SHA256 signature for the payload body."""
151+
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
152+
153+
154+
def send_webhook(url: str, payload: WebhookPayload, secret: str = "") -> bool:
155+
"""Send score notification to a webhook URL.
156+
157+
Auto-detects Slack vs Discord from URL and formats accordingly.
158+
Returns True on success, False on any failure. Never raises.
159+
"""
160+
try:
161+
fmt = detect_format(url)
162+
if fmt == "slack":
163+
data = format_slack(payload)
164+
elif fmt == "discord":
165+
data = format_discord(payload)
166+
else:
167+
data = payload.to_dict()
168+
169+
body = json.dumps(data).encode("utf-8")
170+
171+
headers = {"Content-Type": "application/json"}
172+
if secret:
173+
headers["X-Arbiter-Signature"] = _sign_payload(body, secret)
174+
175+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
176+
with urllib.request.urlopen(req, timeout=10) as resp:
177+
return resp.status < 400
178+
except Exception:
179+
return False

0 commit comments

Comments
 (0)