loki-mode 11.7.1 → 11.9.1
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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +40 -4
- package/autonomy/lib/chat.sh +3 -2
- package/autonomy/lib/workspace.py +95 -4
- package/autonomy/loki +185 -106
- package/autonomy/trigger-server.py +550 -5
- package/autonomy/verify.sh +5 -0
- package/bin/loki +1 -1
- package/completions/_loki +34 -1
- package/completions/loki.bash +10 -3
- package/dashboard/__init__.py +1 -1
- package/docs/ARCHITECTURE.md +0 -1
- package/docs/CLI-REFERENCE.md +1 -1
- package/docs/COMPONENTS.md +1 -1
- package/docs/DEPLOY-PLAN.md +13 -3
- package/docs/INSTALLATION.md +1 -1
- package/docs/WORKSPACES.md +1 -1
- package/docs/dev/architecture-reference.md +1 -1
- package/docs/sentry-intake.md +36 -0
- package/docs/v10/D61-SPEED.md +14 -0
- package/docs/v10/PROGRESS.md +22 -0
- package/loki-ts/dist/loki.js +962 -879
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/packages/control-plane/dist/ask-tools-server.js +13 -4
- package/packages/control-plane/dist/server.js +614 -456
- package/packages/control-plane/src/server/diffstat.ts +10 -2
- package/packages/control-plane/src/server/legacy/routes-control.ts +8 -4
- package/packages/control-plane/src/server/routes/checkpoints.ts +11 -2
- package/packages/control-plane/ui/dist/assets/index-u8cd6TRQ.js +190 -0
- package/packages/control-plane/ui/dist/index.html +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-S3BYeapm.js → AdminPage-BdH9iL2I.js} +1 -1
- package/web-app/dist/assets/{Avatar-C1rHwLgn.js → Avatar-C-WixxR6.js} +1 -1
- package/web-app/dist/assets/{Badge-CzlnN5aQ.js → Badge-BFWpRd_L.js} +1 -1
- package/web-app/dist/assets/{Button-B1w5AeGC.js → Button-CE5vG66Y.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-Dyeqjfkc.js → CockpitPage-BAVn10bY.js} +1 -1
- package/web-app/dist/assets/{ComparePage-C6y2GkGR.js → ComparePage-DOecIGfM.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-Dg2fcGkR.js → ErrorBoundary-1rNA5v7f.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-CxbyjvTj.js → EvidenceReceiptPanel-DjOaWfdH.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-Dpys1xDb.js → GitHubIssuesPanel-NNPBuRPD.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CRVjWoFl.js → GitHubPRsPanel-CL1b--tV.js} +1 -1
- package/web-app/dist/assets/{HomePage-BKnv-oYw.js → HomePage-BsLDN6MW.js} +2 -2
- package/web-app/dist/assets/{LoginPage-BGksmFRd.js → LoginPage-CVIv5bCS.js} +1 -1
- package/web-app/dist/assets/{MagicPage-OCvhp16M.js → MagicPage-BgMBB9lK.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-CJOJpXWC.js → MetricsPage-BHeJlqPC.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-C04p3QvN.js → NotFoundPage-DDzwfyDK.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BRB8txsY.js → ProjectPage-CuQWEYBF.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-CoUYeQEh.js → ProjectsPage-OoD0RFDZ.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-BkjmJGfM.js → SettingsPage-tMFMKkK5.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-B4vU9TjQ.js → ShowcasePage-DVfLoWdz.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-Z2jiVsni.js → SystemSettingsPage-CThV9N4l.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-Ulm8SISq.js → TeamsPage-CV0nvXI8.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-C_7MM6Ey.js → TemplatesPage-D81EI1L8.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-Cumd7Tgs.js → TerminalOutput-Bj7uKRIe.js} +1 -1
- package/web-app/dist/assets/{activity-CZAUkbiY.js → activity-DoNfNkOL.js} +1 -1
- package/web-app/dist/assets/{bell-BOvSXpEr.js → bell-BWO3OU50.js} +1 -1
- package/web-app/dist/assets/{bot-DOTZxBYj.js → bot-DcvWv_zD.js} +1 -1
- package/web-app/dist/assets/{check-BxpE0B5u.js → check-CfG09Z18.js} +1 -1
- package/web-app/dist/assets/{chevron-left-D6KPvBOA.js → chevron-left-z0fh4x2h.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Bajq86oe.js → circle-alert-C_7GlYul.js} +1 -1
- package/web-app/dist/assets/{clock-CnlHAVJc.js → clock-BsYUp070.js} +1 -1
- package/web-app/dist/assets/{cloud-DjaymjwM.js → cloud-BVWAJ7Rv.js} +1 -1
- package/web-app/dist/assets/{code-xml-BADEw2Ld.js → code-xml-CdZT-6zu.js} +1 -1
- package/web-app/dist/assets/{database-B4aKb5Cq.js → database-Dbkm9t2D.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-DrldXpja.js → dollar-sign-CfIUM5Pd.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-DVebDVR-.js → file-code-corner-CrUKhnj-.js} +1 -1
- package/web-app/dist/assets/{file-plus-BD-N_-Tl.js → file-plus-Cf6u0xHY.js} +1 -1
- package/web-app/dist/assets/{globe-C7ieInC-.js → globe-DsIqaI7w.js} +1 -1
- package/web-app/dist/assets/{hammer-BSugKIji.js → hammer-CA14BeMp.js} +1 -1
- package/web-app/dist/assets/{index-nLQgfvOn.js → index-DpJ_GJuZ.js} +3 -3
- package/web-app/dist/assets/{layers-CHXxU2on.js → layers-Dh5Oqhrj.js} +1 -1
- package/web-app/dist/assets/{loader-circle-BEEz0LbD.js → loader-circle-B0WHIGqt.js} +1 -1
- package/web-app/dist/assets/{lock-CzQGfcnE.js → lock-DkgfEGYF.js} +1 -1
- package/web-app/dist/assets/{package-CcHYQLGd.js → package-LzjmwD1S.js} +1 -1
- package/web-app/dist/assets/{plus-DdzgFEXn.js → plus-BNjA0KSV.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-7x0HbH9Y.js → refresh-cw-k3IacuDX.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DBahX1Sp.js → rotate-ccw-BFON_xv-.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BB8y8a9Z.js → scroll-text-CaIlSPZB.js} +1 -1
- package/web-app/dist/assets/{server-CfkbW2MH.js → server-CKFUccCG.js} +1 -1
- package/web-app/dist/assets/{shield-alert-DUgD8rBF.js → shield-alert-Q4-rPo5r.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-DGoyY6QC.js → thumbs-up-BYMeh8oV.js} +1 -1
- package/web-app/dist/assets/{trash-2-B6xrSUMr.js → trash-2-BHSUEIHw.js} +1 -1
- package/web-app/dist/assets/{trending-down-B6RGKTOx.js → trending-down-BC_FR2VT.js} +1 -1
- package/web-app/dist/assets/{trending-up-B44DCrt1.js → trending-up-CmA-WmLW.js} +1 -1
- package/web-app/dist/assets/{upload-4kWm33ZY.js → upload-BxLgBa1B.js} +1 -1
- package/web-app/dist/assets/{usePolling-zkUtRQG6.js → usePolling-DKNgwt5n.js} +1 -1
- package/web-app/dist/assets/{user-04cEVIe2.js → user-CA2DlmLc.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/memory/rag_injector.py +0 -161
- package/packages/control-plane/ui/dist/assets/index-DbLzr87u.js +0 -190
|
@@ -38,6 +38,7 @@ Usage:
|
|
|
38
38
|
"""
|
|
39
39
|
|
|
40
40
|
import argparse
|
|
41
|
+
import base64
|
|
41
42
|
import collections
|
|
42
43
|
import hashlib
|
|
43
44
|
import hmac
|
|
@@ -55,8 +56,10 @@ import subprocess
|
|
|
55
56
|
import sys
|
|
56
57
|
import threading
|
|
57
58
|
import time
|
|
58
|
-
|
|
59
|
+
import urllib.parse
|
|
60
|
+
from datetime import datetime, timezone
|
|
59
61
|
from pathlib import Path
|
|
62
|
+
from urllib.request import Request, urlopen
|
|
60
63
|
|
|
61
64
|
# Receipt attestation. Imported by path because trigger-server.py runs as a
|
|
62
65
|
# script from an arbitrary cwd, so a bare `import receipt_jwt` would depend on
|
|
@@ -412,6 +415,12 @@ CHILD_ENV_DENYLIST = (
|
|
|
412
415
|
"LOKI_API_TOKEN_FILE",
|
|
413
416
|
"LOKI_SENTRY_WEBHOOK_SECRET",
|
|
414
417
|
"SENTRY_CLIENT_SECRET",
|
|
418
|
+
"SLACK_SIGNING_SECRET",
|
|
419
|
+
"SLACK_BOT_TOKEN",
|
|
420
|
+
"LINEAR_WEBHOOK_SECRET",
|
|
421
|
+
"LINEAR_API_KEY",
|
|
422
|
+
"JIRA_WEBHOOK_SECRET",
|
|
423
|
+
"TEAMS_SECURITY_TOKEN",
|
|
415
424
|
)
|
|
416
425
|
|
|
417
426
|
|
|
@@ -577,6 +586,14 @@ def handle_workflow_run_event(payload, dry_run=False):
|
|
|
577
586
|
return summary, status
|
|
578
587
|
|
|
579
588
|
|
|
589
|
+
def brief_start_args(brief):
|
|
590
|
+
"""argv for a delegated PR run. `--brief` forces brief mode so the text is
|
|
591
|
+
never reclassified as a PRD path or issue URL, and `--pr --detach` are only
|
|
592
|
+
honored by `loki start` in brief mode because cmd_start routes them through
|
|
593
|
+
the worktree + PR path."""
|
|
594
|
+
return ["start", "--brief", brief, "--pr", "--detach"]
|
|
595
|
+
|
|
596
|
+
|
|
580
597
|
def handle_job_event(payload, dry_run=False):
|
|
581
598
|
"""Handle a remotely-submitted job: loki start <spec> --detach.
|
|
582
599
|
|
|
@@ -588,7 +605,11 @@ def handle_job_event(payload, dry_run=False):
|
|
|
588
605
|
spec = payload.get("spec")
|
|
589
606
|
if not valid_spec(spec):
|
|
590
607
|
return None, "rejected (invalid spec)"
|
|
591
|
-
|
|
608
|
+
# Jira/Teams delegation sets pr=True: receipt-gated PR run, never a deploy.
|
|
609
|
+
if payload.get("pr") is True:
|
|
610
|
+
args = brief_start_args(spec.strip())
|
|
611
|
+
else:
|
|
612
|
+
args = ["start", spec.strip(), "--detach"]
|
|
592
613
|
summary = "job %s: %s" % (payload.get("job_id", "?"), spec.strip())
|
|
593
614
|
# A remote submitter gates CI on this, so report the BUILD's outcome, not
|
|
594
615
|
# merely that a build was launched. "fired" (launched) and "passed" must
|
|
@@ -622,6 +643,250 @@ def validate_sentry_signature(secret, body, signature_header):
|
|
|
622
643
|
return constant_time_equals(expected, signature_header.strip().lower())
|
|
623
644
|
|
|
624
645
|
|
|
646
|
+
# Slack and Linear delegation. Same signed-body contract as
|
|
647
|
+
# loki-ts/src/contrib/slack_inbound.ts (signSlackBody/verifySlackSignature).
|
|
648
|
+
SIGNATURE_MAX_SKEW_S = 300
|
|
649
|
+
SLACK_REPLY_PREFIX = "https://hooks.slack.com/"
|
|
650
|
+
LINEAR_API_URL = "https://api.linear.app/graphql"
|
|
651
|
+
LINEAR_IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$")
|
|
652
|
+
NOTIFY_TIMEOUT_SECONDS = 10
|
|
653
|
+
NOTIFY_TEXT_MAX = 1000
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def sign_slack_body(secret, timestamp, body):
|
|
657
|
+
"""`v0=` + hex HMAC-SHA256 over `v0:<timestamp>:<raw body>` (bytes)."""
|
|
658
|
+
base = b"v0:" + str(timestamp).encode("utf-8") + b":" + body
|
|
659
|
+
return "v0=" + hmac.new(secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def verify_slack_signature(secret, timestamp, body, signature, now=None):
|
|
663
|
+
"""Fail closed: no secret, header, numeric timestamp or fresh window -> False."""
|
|
664
|
+
if not secret or not timestamp or not signature:
|
|
665
|
+
return False
|
|
666
|
+
if not re.fullmatch(r"[0-9]+", timestamp):
|
|
667
|
+
return False
|
|
668
|
+
now = time.time() if now is None else now
|
|
669
|
+
if abs(now - int(timestamp)) > SIGNATURE_MAX_SKEW_S:
|
|
670
|
+
return False
|
|
671
|
+
return constant_time_equals(sign_slack_body(secret, timestamp, body), signature)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def verify_linear_signature(secret, body, signature):
|
|
675
|
+
"""Linear-Signature: hex HMAC-SHA256 of the raw body. Freshness is checked
|
|
676
|
+
separately against the signed body's webhookTimestamp (linear_fresh)."""
|
|
677
|
+
if not secret or not signature:
|
|
678
|
+
return False
|
|
679
|
+
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
680
|
+
return constant_time_equals(expected, signature.strip().lower())
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def linear_fresh(payload, now=None):
|
|
684
|
+
ts_val = payload.get("webhookTimestamp")
|
|
685
|
+
if isinstance(ts_val, bool) or not isinstance(ts_val, (int, float)):
|
|
686
|
+
return False
|
|
687
|
+
secs = ts_val / 1000.0 if ts_val > 1e11 else float(ts_val)
|
|
688
|
+
now = time.time() if now is None else now
|
|
689
|
+
return abs(now - secs) <= SIGNATURE_MAX_SKEW_S
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def resolve_chat_secrets(env, secret, api_token, sentry_secret):
|
|
693
|
+
"""Return (slack_secret, linear_secret), blanking any that equals another
|
|
694
|
+
credential: each authenticates a different party and MUST differ."""
|
|
695
|
+
slack = env.get("SLACK_SIGNING_SECRET", "")
|
|
696
|
+
linear = env.get("LINEAR_WEBHOOK_SECRET", "")
|
|
697
|
+
others = [x for x in (secret, api_token, sentry_secret) if x]
|
|
698
|
+
slack_bad = bool(slack) and any(
|
|
699
|
+
constant_time_equals(slack, o) for o in others + ([linear] if linear else []))
|
|
700
|
+
linear_bad = bool(linear) and any(
|
|
701
|
+
constant_time_equals(linear, o) for o in others + ([slack] if slack else []))
|
|
702
|
+
if slack_bad:
|
|
703
|
+
logging.error("The Slack secret is identical to another credential. "
|
|
704
|
+
"They MUST differ; disabling /slack.")
|
|
705
|
+
if linear_bad:
|
|
706
|
+
logging.error("The Linear secret is identical to another credential. "
|
|
707
|
+
"They MUST differ; disabling /linear.")
|
|
708
|
+
return ("" if slack_bad else slack), ("" if linear_bad else linear)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# Jira Cloud and Microsoft Teams delegation. Both enqueue a PR-only run.
|
|
712
|
+
JIRA_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*-\d+$")
|
|
713
|
+
JIRA_EVENTS = ("jira:issue_created", "jira:issue_updated")
|
|
714
|
+
JIRA_TRIGGER_FIELDS = ("assignee", "labels")
|
|
715
|
+
DELEGATED_BRIEF_MAX = 1500
|
|
716
|
+
HTML_TAG_RE = re.compile(r"<[^>]*>")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def verify_jira_signature(secret, body, signature):
|
|
720
|
+
"""X-Hub-Signature: `sha256=` + hex HMAC-SHA256 of the raw body. Jira sends
|
|
721
|
+
no timestamp, so replay is bounded by delivery-id dedup in the handler."""
|
|
722
|
+
if not secret or not signature:
|
|
723
|
+
return False
|
|
724
|
+
sig = signature.strip().lower()
|
|
725
|
+
if not sig.startswith("sha256="):
|
|
726
|
+
return False
|
|
727
|
+
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
728
|
+
return constant_time_equals(expected, sig[len("sha256="):])
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def sign_teams_body(token, body):
|
|
732
|
+
"""Base64 HMAC-SHA256 of the raw body, keyed by the base64-decoded token."""
|
|
733
|
+
key = base64.b64decode(token, validate=True)
|
|
734
|
+
return base64.b64encode(hmac.new(key, body, hashlib.sha256).digest()).decode("ascii")
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def verify_teams_signature(token, body, authorization):
|
|
738
|
+
"""Authorization: `HMAC <base64>`. Fails closed on a bad token or header."""
|
|
739
|
+
if not token or not authorization:
|
|
740
|
+
return False
|
|
741
|
+
scheme, _, value = authorization.strip().partition(" ")
|
|
742
|
+
if scheme.upper() != "HMAC" or not value.strip():
|
|
743
|
+
return False
|
|
744
|
+
try:
|
|
745
|
+
expected = sign_teams_body(token, body)
|
|
746
|
+
except ValueError:
|
|
747
|
+
return False
|
|
748
|
+
return constant_time_equals(expected, value.strip())
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def teams_fresh(activity, now=None):
|
|
752
|
+
"""The activity's ISO-8601 `timestamp` must be within the skew window."""
|
|
753
|
+
raw = activity.get("timestamp")
|
|
754
|
+
if not isinstance(raw, str) or not raw:
|
|
755
|
+
return False
|
|
756
|
+
try:
|
|
757
|
+
dt = datetime.fromisoformat(raw.strip().replace("Z", "+00:00"))
|
|
758
|
+
except ValueError:
|
|
759
|
+
return False
|
|
760
|
+
if dt.tzinfo is None:
|
|
761
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
762
|
+
now = time.time() if now is None else now
|
|
763
|
+
return abs(now - dt.timestamp()) <= SIGNATURE_MAX_SKEW_S
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _csv_set(value):
|
|
767
|
+
return frozenset(x.strip() for x in value.split(",") if x.strip())
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _one_line(text, limit=DELEGATED_BRIEF_MAX):
|
|
771
|
+
text = " ".join(CONTROL_CHARS_RE.sub(" ", str(text or "")).split())
|
|
772
|
+
return text.encode("utf-8")[:limit].decode("utf-8", errors="ignore")
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def jira_trigger_matches(payload, assignees, labels):
|
|
776
|
+
"""True when the issue is assigned to a configured user or carries a
|
|
777
|
+
configured label. An update only counts if it changed assignee or labels,
|
|
778
|
+
so unrelated edits never re-fire a run."""
|
|
779
|
+
issue = payload.get("issue")
|
|
780
|
+
fields = issue.get("fields") if isinstance(issue, dict) else None
|
|
781
|
+
if not isinstance(fields, dict):
|
|
782
|
+
return False
|
|
783
|
+
if payload.get("webhookEvent") == "jira:issue_updated":
|
|
784
|
+
items = (payload.get("changelog") or {}).get("items")
|
|
785
|
+
changed = ({i.get("field") for i in items if isinstance(i, dict)}
|
|
786
|
+
if isinstance(items, list) else set())
|
|
787
|
+
if not changed.intersection(JIRA_TRIGGER_FIELDS):
|
|
788
|
+
return False
|
|
789
|
+
who = fields.get("assignee")
|
|
790
|
+
ids = set()
|
|
791
|
+
if isinstance(who, dict):
|
|
792
|
+
for k in ("accountId", "emailAddress", "displayName"):
|
|
793
|
+
if isinstance(who.get(k), str):
|
|
794
|
+
ids.add(who[k])
|
|
795
|
+
if ids.intersection(assignees):
|
|
796
|
+
return True
|
|
797
|
+
have = fields.get("labels")
|
|
798
|
+
return isinstance(have, list) and bool(
|
|
799
|
+
{x for x in have if isinstance(x, str)}.intersection(labels))
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def adf_text(node, out=None):
|
|
803
|
+
"""Flatten an Atlassian Document Format tree to its text nodes."""
|
|
804
|
+
top = out is None
|
|
805
|
+
out = [] if top else out
|
|
806
|
+
if isinstance(node, dict):
|
|
807
|
+
if isinstance(node.get("text"), str):
|
|
808
|
+
out.append(node["text"])
|
|
809
|
+
for child in node.get("content") or []:
|
|
810
|
+
adf_text(child, out)
|
|
811
|
+
elif isinstance(node, list):
|
|
812
|
+
for child in node:
|
|
813
|
+
adf_text(child, out)
|
|
814
|
+
return " ".join(out) if top else None
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def build_jira_brief(issue):
|
|
818
|
+
fields = issue.get("fields") or {}
|
|
819
|
+
desc = fields.get("description")
|
|
820
|
+
if isinstance(desc, dict):
|
|
821
|
+
desc = adf_text(desc)
|
|
822
|
+
parts = ["Resolve Jira issue %s: %s." % (issue.get("key"), fields.get("summary") or "")]
|
|
823
|
+
if isinstance(desc, str) and desc.strip():
|
|
824
|
+
parts.append(desc)
|
|
825
|
+
parts.append("Reproduce with a regression test, then fix the root cause.")
|
|
826
|
+
return _one_line(" ".join(parts))
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def build_teams_brief(activity):
|
|
830
|
+
text = re.sub(r"<at>.*?</at>", " ", str(activity.get("text") or ""), flags=re.S)
|
|
831
|
+
return _one_line(HTML_TAG_RE.sub(" ", text))
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def resolve_delegation_secrets(env, others):
|
|
835
|
+
"""Return (jira_secret, teams_token), blanking any equal to another
|
|
836
|
+
credential in `others` or to each other."""
|
|
837
|
+
jira = env.get("JIRA_WEBHOOK_SECRET", "")
|
|
838
|
+
teams = env.get("TEAMS_SECURITY_TOKEN", "")
|
|
839
|
+
others = [x for x in others if x]
|
|
840
|
+
out = []
|
|
841
|
+
for name, val, peer in (("Jira", jira, teams), ("Teams", teams, jira)):
|
|
842
|
+
bad = bool(val) and any(constant_time_equals(val, o)
|
|
843
|
+
for o in others + ([peer] if peer else []))
|
|
844
|
+
if bad:
|
|
845
|
+
logging.error("The %s secret is identical to another credential. "
|
|
846
|
+
"They MUST differ; disabling its route.", name)
|
|
847
|
+
out.append("" if bad else val)
|
|
848
|
+
return tuple(out)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _post_json(url, data, headers):
|
|
852
|
+
"""POST JSON with a bounded timeout. Raises on any failure; callers log."""
|
|
853
|
+
req = Request(url, data=json.dumps(data).encode("utf-8"),
|
|
854
|
+
headers=dict(headers, **{"Content-Type": "application/json"}),
|
|
855
|
+
method="POST")
|
|
856
|
+
with urlopen(req, timeout=NOTIFY_TIMEOUT_SECONDS) as resp:
|
|
857
|
+
resp.read()
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def notify_job_complete(target, job_id, status, summary):
|
|
861
|
+
"""Send a completion summary to a reply target captured after verification.
|
|
862
|
+
|
|
863
|
+
Never raises: a notifier failure must not fail the job. The Slack URL is
|
|
864
|
+
re-checked here as defense in depth.
|
|
865
|
+
"""
|
|
866
|
+
if not isinstance(target, dict):
|
|
867
|
+
return
|
|
868
|
+
text = ("loki job %s finished: %s. %s" % (job_id, status, summary or "")).strip()
|
|
869
|
+
text = CONTROL_CHARS_RE.sub(" ", text)[:NOTIFY_TEXT_MAX]
|
|
870
|
+
try:
|
|
871
|
+
if target.get("kind") == "slack":
|
|
872
|
+
url = target.get("response_url", "")
|
|
873
|
+
if not (isinstance(url, str) and url.startswith(SLACK_REPLY_PREFIX)):
|
|
874
|
+
return
|
|
875
|
+
_post_json(url, {"response_type": "in_channel", "text": text}, {})
|
|
876
|
+
elif target.get("kind") == "linear":
|
|
877
|
+
key = os.environ.get("LINEAR_API_KEY", "")
|
|
878
|
+
issue_id = target.get("issue_id", "")
|
|
879
|
+
if not key or not isinstance(issue_id, str) or not issue_id:
|
|
880
|
+
return
|
|
881
|
+
_post_json(LINEAR_API_URL, {
|
|
882
|
+
"query": "mutation($i:String!,$b:String!){commentCreate(input:"
|
|
883
|
+
"{issueId:$i,body:$b}){success}}",
|
|
884
|
+
"variables": {"i": issue_id, "b": text},
|
|
885
|
+
}, {"Authorization": key})
|
|
886
|
+
except Exception as e: # notifier errors never fail a job
|
|
887
|
+
logging.warning("Completion notify for job %s failed: %s", job_id, e)
|
|
888
|
+
|
|
889
|
+
|
|
625
890
|
def normalize_sentry_payload(payload):
|
|
626
891
|
"""Run the existing normalize_sentry_json over a Sentry webhook payload.
|
|
627
892
|
|
|
@@ -688,7 +953,7 @@ def handle_sentry_event(payload, dry_run=False):
|
|
|
688
953
|
brief = build_sentry_brief(norm)
|
|
689
954
|
if not valid_spec(brief):
|
|
690
955
|
return None, "rejected (invalid brief)"
|
|
691
|
-
args =
|
|
956
|
+
args = brief_start_args(brief)
|
|
692
957
|
summary = "sentry issue %s" % norm.get("number", "?")
|
|
693
958
|
success = run_loki_command(args, dry_run=dry_run)
|
|
694
959
|
status = "fired" if success else "error"
|
|
@@ -750,6 +1015,9 @@ class Dispatcher:
|
|
|
750
1015
|
self._job_max = max(1, job_history)
|
|
751
1016
|
self._jobs = collections.OrderedDict()
|
|
752
1017
|
self._jobs_lock = threading.Lock()
|
|
1018
|
+
# Reply targets (Slack response_url / Linear issue) keyed by job_id,
|
|
1019
|
+
# written only by the verified /slack and /linear handlers.
|
|
1020
|
+
self._reply_targets = collections.OrderedDict()
|
|
753
1021
|
# Proof-attribution bookkeeping. The proof pointer is global to the
|
|
754
1022
|
# working directory, so a receipt only identifies a job when that job
|
|
755
1023
|
# was the only build that could have written it. All three are updated
|
|
@@ -887,6 +1155,17 @@ class Dispatcher:
|
|
|
887
1155
|
if summary:
|
|
888
1156
|
entry["summary"] = summary
|
|
889
1157
|
|
|
1158
|
+
def set_reply_target(self, job_id, target):
|
|
1159
|
+
"""Store a verified reply target server side, bounded like _jobs."""
|
|
1160
|
+
with self._jobs_lock:
|
|
1161
|
+
self._reply_targets[job_id] = target
|
|
1162
|
+
while len(self._reply_targets) > self._job_max:
|
|
1163
|
+
self._reply_targets.popitem(last=False)
|
|
1164
|
+
|
|
1165
|
+
def pop_reply_target(self, job_id):
|
|
1166
|
+
with self._jobs_lock:
|
|
1167
|
+
return self._reply_targets.pop(job_id, None)
|
|
1168
|
+
|
|
890
1169
|
def get_job(self, job_id):
|
|
891
1170
|
"""Return a copy of the job record, or None if unknown/evicted.
|
|
892
1171
|
|
|
@@ -980,6 +1259,9 @@ class Dispatcher:
|
|
|
980
1259
|
)
|
|
981
1260
|
if job_id:
|
|
982
1261
|
self.record_job(job_id, status, summary or "")
|
|
1262
|
+
target = self.pop_reply_target(job_id)
|
|
1263
|
+
if target is not None:
|
|
1264
|
+
notify_job_complete(target, job_id, status, summary)
|
|
983
1265
|
finally:
|
|
984
1266
|
self.queue.task_done()
|
|
985
1267
|
|
|
@@ -998,6 +1280,16 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
998
1280
|
api_token = ""
|
|
999
1281
|
# Sentry integration client secret for /sentry. Distinct from both above.
|
|
1000
1282
|
sentry_secret = ""
|
|
1283
|
+
# Slack signing secret (/slack) and Linear webhook secret (/linear), each
|
|
1284
|
+
# distinct from every other credential.
|
|
1285
|
+
slack_secret = ""
|
|
1286
|
+
linear_secret = ""
|
|
1287
|
+
# Jira webhook secret (/jira), Teams outgoing-webhook token (/teams), and
|
|
1288
|
+
# the Jira trigger filters (assignee ids/emails/names, labels).
|
|
1289
|
+
jira_secret = ""
|
|
1290
|
+
teams_secret = ""
|
|
1291
|
+
jira_assignees = frozenset()
|
|
1292
|
+
jira_labels = frozenset()
|
|
1001
1293
|
dispatcher = None
|
|
1002
1294
|
# Ed25519 key that attests receipts, and the retired public keys that keep
|
|
1003
1295
|
# pre-rotation receipts verifiable. Both default to unconfigured, which
|
|
@@ -1278,8 +1570,21 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1278
1570
|
self._send_json(400, {"error": "invalid spec"})
|
|
1279
1571
|
return
|
|
1280
1572
|
|
|
1573
|
+
job_id = self._enqueue_job(spec.strip())
|
|
1574
|
+
if job_id is None:
|
|
1575
|
+
return
|
|
1576
|
+
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
1577
|
+
|
|
1578
|
+
def _enqueue_job(self, spec, reply_target=None, pr=False):
|
|
1579
|
+
"""Record and enqueue one validated job; shared by /jobs, /slack, /linear.
|
|
1580
|
+
|
|
1581
|
+
Returns the job id, or None after sending 429 when the queue is full.
|
|
1582
|
+
reply_target must come from an already-verified request.
|
|
1583
|
+
"""
|
|
1281
1584
|
job_id = secrets.token_urlsafe(12)
|
|
1282
|
-
job = {"spec": spec
|
|
1585
|
+
job = {"spec": spec, "job_id": job_id}
|
|
1586
|
+
if pr:
|
|
1587
|
+
job["pr"] = True
|
|
1283
1588
|
|
|
1284
1589
|
# Record BEFORE enqueueing. A worker can pick the job up the instant
|
|
1285
1590
|
# submit() returns and write "running"/"fired"; record_job is
|
|
@@ -1288,6 +1593,8 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1288
1593
|
# forever. On the 429 path below the record is unreachable (no id is
|
|
1289
1594
|
# handed out) and the store is bounded, so nothing leaks.
|
|
1290
1595
|
self.dispatcher.record_job(job_id, "queued", job["spec"])
|
|
1596
|
+
if reply_target is not None:
|
|
1597
|
+
self.dispatcher.set_reply_target(job_id, reply_target)
|
|
1291
1598
|
|
|
1292
1599
|
# Enforce the existing queue bound so a submit storm cannot fork
|
|
1293
1600
|
# unbounded builds. 429 (not a silent drop) tells the client to retry.
|
|
@@ -1295,13 +1602,207 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1295
1602
|
logging.warning(
|
|
1296
1603
|
"Queue full; shedding job submit from %s", self.address_string()
|
|
1297
1604
|
)
|
|
1605
|
+
self.dispatcher.pop_reply_target(job_id)
|
|
1298
1606
|
log_event(JOB_EVENT, "", "", "rejected (queue full)")
|
|
1299
1607
|
self._send_json(429, {"error": "server busy, retry later"})
|
|
1300
|
-
return
|
|
1608
|
+
return None
|
|
1301
1609
|
|
|
1302
1610
|
log_event(JOB_EVENT, "", job["spec"], "queued")
|
|
1611
|
+
return job_id
|
|
1612
|
+
|
|
1613
|
+
def _handle_slack(self):
|
|
1614
|
+
"""POST /slack -- signed slash command; enqueues a job, ephemeral reply."""
|
|
1615
|
+
body = self._read_body()
|
|
1616
|
+
if body is None:
|
|
1617
|
+
return
|
|
1618
|
+
if not self.slack_secret:
|
|
1619
|
+
log_event(JOB_EVENT, "", "", "rejected (no slack signing secret configured)")
|
|
1620
|
+
self._send_json(503, {"error": "SLACK_SIGNING_SECRET not configured"})
|
|
1621
|
+
return
|
|
1622
|
+
if not verify_slack_signature(
|
|
1623
|
+
self.slack_secret,
|
|
1624
|
+
self.headers.get("X-Slack-Request-Timestamp", ""),
|
|
1625
|
+
body, self.headers.get("X-Slack-Signature", "")):
|
|
1626
|
+
logging.warning("Invalid Slack signature from %s", self.address_string())
|
|
1627
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid slack signature)")
|
|
1628
|
+
self._send_json(401, {"error": "invalid signature"})
|
|
1629
|
+
return
|
|
1630
|
+
# Verified from here on.
|
|
1631
|
+
ctype = (self.headers.get("Content-Type", "") or "").lower()
|
|
1632
|
+
if "json" in ctype:
|
|
1633
|
+
try:
|
|
1634
|
+
data = json.loads(body)
|
|
1635
|
+
except ValueError:
|
|
1636
|
+
self._send_json(400, {"error": "invalid JSON"})
|
|
1637
|
+
return
|
|
1638
|
+
if isinstance(data, dict) and data.get("type") == "url_verification":
|
|
1639
|
+
self._send_json(200, {"challenge": str(data.get("challenge", ""))})
|
|
1640
|
+
return
|
|
1641
|
+
self._send_json(400, {"error": "unsupported slack payload"})
|
|
1642
|
+
return
|
|
1643
|
+
form = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"))
|
|
1644
|
+
text = (form.get("text") or [""])[0]
|
|
1645
|
+
if not valid_spec(text):
|
|
1646
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid slack spec)")
|
|
1647
|
+
self._send_json(200, {"response_type": "ephemeral",
|
|
1648
|
+
"text": "Usage: /loki <spec>. Spec must be a "
|
|
1649
|
+
"one-line brief or ref, not starting with '-'."})
|
|
1650
|
+
return
|
|
1651
|
+
response_url = (form.get("response_url") or [""])[0]
|
|
1652
|
+
target = {"kind": "slack"}
|
|
1653
|
+
if response_url.startswith(SLACK_REPLY_PREFIX):
|
|
1654
|
+
target["response_url"] = response_url
|
|
1655
|
+
job_id = self._enqueue_job(text.strip(), target)
|
|
1656
|
+
if job_id is None:
|
|
1657
|
+
return
|
|
1658
|
+
self._send_json(200, {"response_type": "ephemeral",
|
|
1659
|
+
"text": "Queued loki job %s" % job_id})
|
|
1660
|
+
|
|
1661
|
+
def _handle_linear(self):
|
|
1662
|
+
"""POST /linear -- signed Linear issue webhook; enqueues linear:<identifier>."""
|
|
1663
|
+
body = self._read_body()
|
|
1664
|
+
if body is None:
|
|
1665
|
+
return
|
|
1666
|
+
if not self.linear_secret:
|
|
1667
|
+
log_event(JOB_EVENT, "", "", "rejected (no linear webhook secret configured)")
|
|
1668
|
+
self._send_json(503, {"error": "LINEAR_WEBHOOK_SECRET not configured"})
|
|
1669
|
+
return
|
|
1670
|
+
if not verify_linear_signature(
|
|
1671
|
+
self.linear_secret, body, self.headers.get("Linear-Signature", "")):
|
|
1672
|
+
logging.warning("Invalid Linear signature from %s", self.address_string())
|
|
1673
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid linear signature)")
|
|
1674
|
+
self._send_json(401, {"error": "invalid signature"})
|
|
1675
|
+
return
|
|
1676
|
+
try:
|
|
1677
|
+
payload = json.loads(body)
|
|
1678
|
+
except ValueError:
|
|
1679
|
+
self._send_json(400, {"error": "invalid JSON"})
|
|
1680
|
+
return
|
|
1681
|
+
if not isinstance(payload, dict):
|
|
1682
|
+
self._send_json(400, {"error": "payload must be a JSON object"})
|
|
1683
|
+
return
|
|
1684
|
+
if not linear_fresh(payload):
|
|
1685
|
+
log_event(JOB_EVENT, "", "", "rejected (stale linear timestamp)")
|
|
1686
|
+
self._send_json(401, {"error": "stale or missing webhookTimestamp"})
|
|
1687
|
+
return
|
|
1688
|
+
data = payload.get("data")
|
|
1689
|
+
if (payload.get("type") != "Issue" or payload.get("action") != "create"
|
|
1690
|
+
or not isinstance(data, dict)):
|
|
1691
|
+
self._send_json(200, {"status": "skipped"})
|
|
1692
|
+
return
|
|
1693
|
+
ident = data.get("identifier")
|
|
1694
|
+
if not isinstance(ident, str) or not LINEAR_IDENTIFIER_RE.match(ident):
|
|
1695
|
+
self._send_json(400, {"error": "invalid issue identifier"})
|
|
1696
|
+
return
|
|
1697
|
+
if self.dispatcher.seen_delivery(self.headers.get("Linear-Delivery", "")):
|
|
1698
|
+
self._send_json(200, {"status": "duplicate"})
|
|
1699
|
+
return
|
|
1700
|
+
spec = "linear:%s" % ident
|
|
1701
|
+
if not valid_spec(spec):
|
|
1702
|
+
self._send_json(400, {"error": "invalid spec"})
|
|
1703
|
+
return
|
|
1704
|
+
issue_id = data.get("id")
|
|
1705
|
+
target = {"kind": "linear",
|
|
1706
|
+
"issue_id": issue_id if isinstance(issue_id, str) else ""}
|
|
1707
|
+
job_id = self._enqueue_job(spec, target)
|
|
1708
|
+
if job_id is None:
|
|
1709
|
+
return
|
|
1710
|
+
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
1711
|
+
|
|
1712
|
+
def _handle_jira(self):
|
|
1713
|
+
"""POST /jira -- signed Jira Cloud webhook; enqueues a PR-only run."""
|
|
1714
|
+
body = self._read_body()
|
|
1715
|
+
if body is None:
|
|
1716
|
+
return
|
|
1717
|
+
if not self.jira_secret:
|
|
1718
|
+
log_event(JOB_EVENT, "", "", "rejected (no jira webhook secret configured)")
|
|
1719
|
+
self._send_json(503, {"error": "JIRA_WEBHOOK_SECRET not configured"})
|
|
1720
|
+
return
|
|
1721
|
+
if not verify_jira_signature(
|
|
1722
|
+
self.jira_secret, body, self.headers.get("X-Hub-Signature", "")):
|
|
1723
|
+
logging.warning("Invalid Jira signature from %s", self.address_string())
|
|
1724
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid jira signature)")
|
|
1725
|
+
self._send_json(401, {"error": "invalid signature"})
|
|
1726
|
+
return
|
|
1727
|
+
try:
|
|
1728
|
+
payload = json.loads(body)
|
|
1729
|
+
except ValueError:
|
|
1730
|
+
self._send_json(400, {"error": "invalid JSON"})
|
|
1731
|
+
return
|
|
1732
|
+
if not isinstance(payload, dict):
|
|
1733
|
+
self._send_json(400, {"error": "payload must be a JSON object"})
|
|
1734
|
+
return
|
|
1735
|
+
if (payload.get("webhookEvent") not in JIRA_EVENTS
|
|
1736
|
+
or not jira_trigger_matches(
|
|
1737
|
+
payload, self.jira_assignees, self.jira_labels)):
|
|
1738
|
+
self._send_json(200, {"status": "skipped"})
|
|
1739
|
+
return
|
|
1740
|
+
issue = payload["issue"]
|
|
1741
|
+
key = issue.get("key")
|
|
1742
|
+
if not isinstance(key, str) or not JIRA_KEY_RE.match(key):
|
|
1743
|
+
self._send_json(400, {"error": "invalid issue key"})
|
|
1744
|
+
return
|
|
1745
|
+
brief = build_jira_brief(issue)
|
|
1746
|
+
if not valid_spec(brief):
|
|
1747
|
+
self._send_json(400, {"error": "invalid brief"})
|
|
1748
|
+
return
|
|
1749
|
+
# Jira has no signed timestamp, and the delivery id header is not covered
|
|
1750
|
+
# by the HMAC, so also dedup on a hash of the signed body: a captured body
|
|
1751
|
+
# cannot be replayed by changing or dropping the header.
|
|
1752
|
+
dup_id = self.dispatcher.seen_delivery(
|
|
1753
|
+
self.headers.get("X-Atlassian-Webhook-Identifier", ""))
|
|
1754
|
+
dup_body = self.dispatcher.seen_delivery(
|
|
1755
|
+
"jira-body:" + hashlib.sha256(body).hexdigest())
|
|
1756
|
+
if dup_id or dup_body:
|
|
1757
|
+
self._send_json(200, {"status": "duplicate"})
|
|
1758
|
+
return
|
|
1759
|
+
job_id = self._enqueue_job(brief, pr=True)
|
|
1760
|
+
if job_id is None:
|
|
1761
|
+
return
|
|
1303
1762
|
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
1304
1763
|
|
|
1764
|
+
def _handle_teams(self):
|
|
1765
|
+
"""POST /teams -- Teams outgoing webhook; enqueues a PR-only run."""
|
|
1766
|
+
body = self._read_body()
|
|
1767
|
+
if body is None:
|
|
1768
|
+
return
|
|
1769
|
+
if not self.teams_secret:
|
|
1770
|
+
log_event(JOB_EVENT, "", "", "rejected (no teams security token configured)")
|
|
1771
|
+
self._send_json(503, {"error": "TEAMS_SECURITY_TOKEN not configured"})
|
|
1772
|
+
return
|
|
1773
|
+
if not verify_teams_signature(
|
|
1774
|
+
self.teams_secret, body, self.headers.get("Authorization", "")):
|
|
1775
|
+
logging.warning("Invalid Teams signature from %s", self.address_string())
|
|
1776
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid teams signature)")
|
|
1777
|
+
self._send_json(401, {"error": "invalid signature"})
|
|
1778
|
+
return
|
|
1779
|
+
try:
|
|
1780
|
+
activity = json.loads(body)
|
|
1781
|
+
except ValueError:
|
|
1782
|
+
self._send_json(400, {"error": "invalid JSON"})
|
|
1783
|
+
return
|
|
1784
|
+
if not isinstance(activity, dict):
|
|
1785
|
+
self._send_json(400, {"error": "payload must be a JSON object"})
|
|
1786
|
+
return
|
|
1787
|
+
if not teams_fresh(activity):
|
|
1788
|
+
log_event(JOB_EVENT, "", "", "rejected (stale teams timestamp)")
|
|
1789
|
+
self._send_json(401, {"error": "stale or missing timestamp"})
|
|
1790
|
+
return
|
|
1791
|
+
brief = build_teams_brief(activity)
|
|
1792
|
+
if not valid_spec(brief):
|
|
1793
|
+
self._send_json(200, {"type": "message",
|
|
1794
|
+
"text": "Usage: @loki <brief>. One-line brief, "
|
|
1795
|
+
"not starting with '-'."})
|
|
1796
|
+
return
|
|
1797
|
+
act_id = activity.get("id")
|
|
1798
|
+
if self.dispatcher.seen_delivery(act_id if isinstance(act_id, str) else ""):
|
|
1799
|
+
self._send_json(200, {"type": "message", "text": "Duplicate, already queued."})
|
|
1800
|
+
return
|
|
1801
|
+
job_id = self._enqueue_job(brief, pr=True)
|
|
1802
|
+
if job_id is None:
|
|
1803
|
+
return
|
|
1804
|
+
self._send_json(200, {"type": "message", "text": "Queued loki job %s" % job_id})
|
|
1805
|
+
|
|
1305
1806
|
def _handle_sentry(self):
|
|
1306
1807
|
"""POST /sentry -- Sentry alert, HMAC-verified, enqueues a fix run."""
|
|
1307
1808
|
body = self._read_body()
|
|
@@ -1347,6 +1848,18 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1347
1848
|
if path == "/sentry":
|
|
1348
1849
|
self._handle_sentry()
|
|
1349
1850
|
return
|
|
1851
|
+
if path == "/slack":
|
|
1852
|
+
self._handle_slack()
|
|
1853
|
+
return
|
|
1854
|
+
if path == "/linear":
|
|
1855
|
+
self._handle_linear()
|
|
1856
|
+
return
|
|
1857
|
+
if path == "/jira":
|
|
1858
|
+
self._handle_jira()
|
|
1859
|
+
return
|
|
1860
|
+
if path == "/teams":
|
|
1861
|
+
self._handle_teams()
|
|
1862
|
+
return
|
|
1350
1863
|
if path != "/webhook":
|
|
1351
1864
|
self._send_json(404, {"error": "not found"})
|
|
1352
1865
|
return
|
|
@@ -1527,6 +2040,16 @@ def main():
|
|
|
1527
2040
|
)
|
|
1528
2041
|
sentry_secret = ""
|
|
1529
2042
|
|
|
2043
|
+
# Slack/Linear secrets: env only. Each must differ from every other
|
|
2044
|
+
# credential, since each authenticates a different party.
|
|
2045
|
+
slack_secret, linear_secret = resolve_chat_secrets(
|
|
2046
|
+
os.environ, secret, api_token, sentry_secret)
|
|
2047
|
+
|
|
2048
|
+
jira_secret, teams_secret = resolve_delegation_secrets(
|
|
2049
|
+
os.environ, [secret, api_token, sentry_secret, slack_secret, linear_secret])
|
|
2050
|
+
WebhookHandler.jira_assignees = _csv_set(os.environ.get("JIRA_ASSIGNEES", ""))
|
|
2051
|
+
WebhookHandler.jira_labels = _csv_set(os.environ.get("JIRA_LABELS", ""))
|
|
2052
|
+
|
|
1530
2053
|
# Persist resolved values, but never write the secret to disk.
|
|
1531
2054
|
config["port"] = port
|
|
1532
2055
|
config["dry_run"] = dry_run
|
|
@@ -1541,6 +2064,10 @@ def main():
|
|
|
1541
2064
|
WebhookHandler.secret = secret
|
|
1542
2065
|
WebhookHandler.api_token = api_token
|
|
1543
2066
|
WebhookHandler.sentry_secret = sentry_secret
|
|
2067
|
+
WebhookHandler.slack_secret = slack_secret
|
|
2068
|
+
WebhookHandler.linear_secret = linear_secret
|
|
2069
|
+
WebhookHandler.jira_secret = jira_secret
|
|
2070
|
+
WebhookHandler.teams_secret = teams_secret
|
|
1544
2071
|
WebhookHandler.dispatcher = dispatcher
|
|
1545
2072
|
|
|
1546
2073
|
signing_key, signing_kid = load_signing_key(auto_generate=False)
|
|
@@ -1587,6 +2114,24 @@ def main():
|
|
|
1587
2114
|
"No Sentry secret configured: POST /sentry is rejected with 503. "
|
|
1588
2115
|
"Set LOKI_SENTRY_WEBHOOK_SECRET or SENTRY_CLIENT_SECRET."
|
|
1589
2116
|
)
|
|
2117
|
+
if not slack_secret:
|
|
2118
|
+
logging.warning(
|
|
2119
|
+
"No Slack signing secret configured: POST /slack is rejected with "
|
|
2120
|
+
"503. Set SLACK_SIGNING_SECRET."
|
|
2121
|
+
)
|
|
2122
|
+
if not linear_secret:
|
|
2123
|
+
logging.warning(
|
|
2124
|
+
"No Linear webhook secret configured: POST /linear is rejected with "
|
|
2125
|
+
"503. Set LINEAR_WEBHOOK_SECRET."
|
|
2126
|
+
)
|
|
2127
|
+
if not jira_secret:
|
|
2128
|
+
logging.warning(
|
|
2129
|
+
"No Jira webhook secret configured: POST /jira is rejected with "
|
|
2130
|
+
"503. Set JIRA_WEBHOOK_SECRET.")
|
|
2131
|
+
if not teams_secret:
|
|
2132
|
+
logging.warning(
|
|
2133
|
+
"No Teams security token configured: POST /teams is rejected with "
|
|
2134
|
+
"503. Set TEAMS_SECURITY_TOKEN.")
|
|
1590
2135
|
if not secret:
|
|
1591
2136
|
logging.warning(
|
|
1592
2137
|
"No webhook secret configured: ALL webhooks will be rejected with "
|