|
| 1 | +"""Submit explicit user feedback (/codeboarding-feedback) to PostHog. |
| 2 | +
|
| 3 | +Standard-library only, on purpose: this runs in the action's guard phase, before |
| 4 | +the engine checkout and any dependency install, so it must not import third-party |
| 5 | +packages. Unlike Core's anonymous telemetry, this event intentionally carries the |
| 6 | +user-written feedback text and PR context — that difference is documented in the |
| 7 | +README. All sending failures are swallowed; feedback must never break a PR. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import json |
| 13 | +import os |
| 14 | +import sys |
| 15 | +import urllib.error |
| 16 | +import urllib.request |
| 17 | + |
| 18 | +# Public PostHog ingest key — the same write-only project key Core ships. |
| 19 | +DEFAULT_POSTHOG_KEY = "phc_BQWpoXuPYQhW7mPWQcRv4yzSfuoAmh48EmXuUpeXPUB2" |
| 20 | +DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" |
| 21 | +DEFAULT_COMMAND = "/codeboarding-feedback" |
| 22 | +DEFAULT_MAX_CHARS = 4000 |
| 23 | +EVENT_NAME = "codeboarding_feedback_submitted" |
| 24 | +SOURCE = "github_action_feedback" |
| 25 | + |
| 26 | + |
| 27 | +def telemetry_disabled(env: dict) -> bool: |
| 28 | + """Mirror Core's opt-out: DO_NOT_TRACK or CODEBOARDING_TELEMETRY=false.""" |
| 29 | + if env.get("DO_NOT_TRACK", "").strip().lower() in ("1", "true", "yes"): |
| 30 | + return True |
| 31 | + return env.get("CODEBOARDING_TELEMETRY", "true").strip().lower() == "false" |
| 32 | + |
| 33 | + |
| 34 | +def resolve_key(env: dict) -> str: |
| 35 | + return (env.get("CODEBOARDING_POSTHOG_KEY") or env.get("POSTHOG_KEY") or DEFAULT_POSTHOG_KEY).strip() |
| 36 | + |
| 37 | + |
| 38 | +def resolve_host(env: dict) -> str: |
| 39 | + host = (env.get("CODEBOARDING_POSTHOG_HOST") or env.get("POSTHOG_HOST") or DEFAULT_POSTHOG_HOST).strip() |
| 40 | + return host.rstrip("/") or DEFAULT_POSTHOG_HOST |
| 41 | + |
| 42 | + |
| 43 | +def resolve_command(env: dict) -> str: |
| 44 | + return (env.get("FEEDBACK_COMMAND") or "").strip() or DEFAULT_COMMAND |
| 45 | + |
| 46 | + |
| 47 | +def resolve_max_chars(env: dict) -> int: |
| 48 | + try: |
| 49 | + n = int((env.get("FEEDBACK_MAX_CHARS") or "").strip()) |
| 50 | + except ValueError: |
| 51 | + return DEFAULT_MAX_CHARS |
| 52 | + return n if n > 0 else DEFAULT_MAX_CHARS |
| 53 | + |
| 54 | + |
| 55 | +def extract_feedback(comment_body: str, command: str) -> str: |
| 56 | + """Return everything after the leading command token, newlines preserved. |
| 57 | +
|
| 58 | + The command is the first whitespace-delimited token of the comment. Only that |
| 59 | + one token is removed; the remainder (including any later lines) is kept |
| 60 | + verbatim, then outer whitespace is trimmed. Returns "" when the comment does |
| 61 | + not actually start with the command, or carries no text after it. |
| 62 | + """ |
| 63 | + body = (comment_body or "").replace("\r\n", "\n").replace("\r", "\n").lstrip() |
| 64 | + if not body: |
| 65 | + return "" |
| 66 | + parts = body.split(None, 1) # split once on the first run of whitespace |
| 67 | + if parts[0] != command: |
| 68 | + return "" |
| 69 | + return parts[1].strip() if len(parts) > 1 else "" |
| 70 | + |
| 71 | + |
| 72 | +def cap_feedback(text: str, max_chars: int) -> tuple[str, int, bool]: |
| 73 | + """Return (capped_text, original_length, truncated).""" |
| 74 | + original_length = len(text) |
| 75 | + truncated = original_length > max_chars |
| 76 | + return (text[:max_chars] if truncated else text), original_length, truncated |
| 77 | + |
| 78 | + |
| 79 | +def _first(env: dict, *names: str) -> str: |
| 80 | + for name in names: |
| 81 | + value = (env.get(name) or "").strip() |
| 82 | + if value: |
| 83 | + return value |
| 84 | + return "" |
| 85 | + |
| 86 | + |
| 87 | +def distinct_id(env: dict) -> str: |
| 88 | + sender_id = _first(env, "SENDER_ID") |
| 89 | + if sender_id: |
| 90 | + return f"github-user:{sender_id}" |
| 91 | + return f"github-run:{_first(env, 'RUN_ID', 'GITHUB_RUN_ID')}" |
| 92 | + |
| 93 | + |
| 94 | +def build_properties(env: dict, command: str, feedback_text: str, feedback_length: int, truncated: bool) -> dict: |
| 95 | + props: dict = { |
| 96 | + "source": SOURCE, |
| 97 | + "command": command, |
| 98 | + "feedback_text": feedback_text, |
| 99 | + "feedback_length": feedback_length, |
| 100 | + "feedback_truncated": truncated, |
| 101 | + } |
| 102 | + optional = { |
| 103 | + "repository": _first(env, "REPOSITORY"), |
| 104 | + "repository_id": _first(env, "REPOSITORY_ID"), |
| 105 | + "pr_number": _first(env, "PR_NUMBER", "ISSUE_NUMBER"), |
| 106 | + "comment_id": _first(env, "COMMENT_ID"), |
| 107 | + "comment_url": _first(env, "COMMENT_URL"), |
| 108 | + "author_association": _first(env, "AUTHOR_ASSOC", "AUTHOR_ASSOCIATION"), |
| 109 | + "sender_login": _first(env, "SENDER_LOGIN"), |
| 110 | + "sender_id": _first(env, "SENDER_ID"), |
| 111 | + "run_id": _first(env, "RUN_ID", "GITHUB_RUN_ID"), |
| 112 | + "run_attempt": _first(env, "RUN_ATTEMPT", "GITHUB_RUN_ATTEMPT"), |
| 113 | + "action_ref": _first(env, "ACTION_REF", "GITHUB_ACTION_REF", "GITHUB_SHA"), |
| 114 | + } |
| 115 | + props.update({key: value for key, value in optional.items() if value}) |
| 116 | + return props |
| 117 | + |
| 118 | + |
| 119 | +def build_payload(env: dict) -> dict | None: |
| 120 | + """Build the PostHog event payload, or None when there is nothing to send.""" |
| 121 | + command = resolve_command(env) |
| 122 | + feedback_text, feedback_length, truncated = cap_feedback( |
| 123 | + extract_feedback(env.get("COMMENT_BODY", ""), command), resolve_max_chars(env) |
| 124 | + ) |
| 125 | + if not feedback_text: |
| 126 | + return None |
| 127 | + return { |
| 128 | + "api_key": resolve_key(env), |
| 129 | + "event": EVENT_NAME, |
| 130 | + "distinct_id": distinct_id(env), |
| 131 | + "properties": build_properties(env, command, feedback_text, feedback_length, truncated), |
| 132 | + } |
| 133 | + |
| 134 | + |
| 135 | +def post(payload: dict, host: str, timeout: int = 10) -> int: |
| 136 | + """POST one event to PostHog's ingest endpoint; return the HTTP status.""" |
| 137 | + request = urllib.request.Request( |
| 138 | + f"{host}/i/v0/e/", |
| 139 | + data=json.dumps(payload).encode("utf-8"), |
| 140 | + headers={"Content-Type": "application/json"}, |
| 141 | + method="POST", |
| 142 | + ) |
| 143 | + with urllib.request.urlopen(request, timeout=timeout) as response: |
| 144 | + return response.status |
| 145 | + |
| 146 | + |
| 147 | +def main(env: dict | None = None) -> int: |
| 148 | + env = os.environ if env is None else env |
| 149 | + |
| 150 | + if telemetry_disabled(env): |
| 151 | + print("Feedback disabled via DO_NOT_TRACK / CODEBOARDING_TELEMETRY; not sending.") |
| 152 | + return 0 |
| 153 | + |
| 154 | + payload = build_payload(env) |
| 155 | + if payload is None: |
| 156 | + print("No feedback text after the command; nothing to send.") |
| 157 | + return 0 |
| 158 | + if not payload["api_key"]: |
| 159 | + print("No PostHog key configured; skipping feedback send.") |
| 160 | + return 0 |
| 161 | + |
| 162 | + truncated = payload["properties"].get("feedback_truncated") |
| 163 | + try: |
| 164 | + status = post(payload, resolve_host(env)) |
| 165 | + print(f"Feedback submitted (HTTP {status}, truncated={truncated}).") |
| 166 | + except urllib.error.HTTPError as exc: |
| 167 | + print(f"Feedback endpoint returned HTTP {exc.code}; ignoring.") |
| 168 | + except urllib.error.URLError as exc: |
| 169 | + print(f"Feedback endpoint unreachable ({type(exc.reason).__name__}); ignoring.") |
| 170 | + except Exception as exc: # never let feedback break the action |
| 171 | + print(f"Feedback send failed ({type(exc).__name__}); ignoring.") |
| 172 | + return 0 |
| 173 | + |
| 174 | + |
| 175 | +if __name__ == "__main__": |
| 176 | + sys.exit(main()) |
0 commit comments