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+
5570def _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
439474def 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+
20002081def 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 )
0 commit comments