From 7d09970c15877cd8fb404dc7b8aa27cfc542f698 Mon Sep 17 00:00:00 2001 From: Mara Nikola Kiefer <8320933+mnkiefer@users.noreply.github.com> Date: Wed, 27 May 2026 14:14:32 +0200 Subject: [PATCH] refactor: discussion feedback workflow and add dispatch script (#197071) * Refactor discussion feedback workflow and add dispatch script * Enhance dispatch error handling and normalize actor login in feedback workflow --- .github/workflows/label-feedback-dispatch.yml | 64 ++----- .../scripts/dispatch_discussion_feedback.py | 172 ++++++++++++++++++ 2 files changed, 188 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/scripts/dispatch_discussion_feedback.py diff --git a/.github/workflows/label-feedback-dispatch.yml b/.github/workflows/label-feedback-dispatch.yml index 0e1c8dde0..2ded3dd13 100644 --- a/.github/workflows/label-feedback-dispatch.yml +++ b/.github/workflows/label-feedback-dispatch.yml @@ -1,8 +1,10 @@ -name: Dispatch Staff Label Feedback +name: Dispatch Discussion Feedback on: discussion: types: + - created + - category_changed - labeled - unlabeled @@ -10,55 +12,21 @@ permissions: contents: read jobs: - dispatch-feedback-event: + dispatch-ops-feedback-event: runs-on: ubuntu-latest env: - COMMUNITY_OPS_REPOSITORY: community/community-ops + TARGET_OPS_REPOSITORY: githubnext/aw-community-ops + DISPATCH_TOKEN: ${{ secrets.COMM_COMM_OPS_DISPATCH_TOKEN }} + PROD_TRUSTED_STAFF: ${{ vars.PROD_TRUSTED_STAFF }} steps: - - name: Forward discussion label events - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.WRITE_TO_COMM_OPS_TOKEN }} - script: | - const actor = context.actor; - const action = context.payload.action; - const discussion = context.payload.discussion; - const label = context.payload.label; + - name: Checkout repository + if: env.DISPATCH_TOKEN != '' + uses: actions/checkout@v4 - if (!label?.name) { - core.info("Skipping event with missing label"); - return; - } + - name: Forward production discussion facts to ops + if: env.DISPATCH_TOKEN != '' + run: python3 .github/workflows/scripts/dispatch_discussion_feedback.py - if (!discussion?.number) { - throw new Error("Missing discussion number in discussion event payload"); - } - - if ((actor || "").endsWith("[bot]")) { - core.info(`Skipping bot-generated event: ${actor}`); - return; - } - - const [owner, repo] = process.env.COMMUNITY_OPS_REPOSITORY.split("/"); - await github.rest.repos.createDispatchEvent({ - owner, - repo, - event_type: "staff-label-correction", - client_payload: { - data: { - source_repository: `${context.repo.owner}/${context.repo.repo}`, - target_repository: `${process.env.COMMUNITY_OPS_REPOSITORY}`, - discussion_number: discussion.number, - discussion_title: discussion.title || "unknown", - discussion_url: discussion.html_url || discussion.url || "", - category: discussion.category?.name || "unknown", - category_slug: discussion.category?.slug || "unknown", - event_type: action, - label: label.name, - actor, - createdAt: new Date().toISOString(), - }, - }, - }); - - core.info(`Forwarded ${action} event for discussion #${discussion.number}`); + - name: Note missing dispatch token + if: env.DISPATCH_TOKEN == '' + run: echo "COMM_COMM_OPS_DISPATCH_TOKEN is not configured; skipping ops dispatch." diff --git a/.github/workflows/scripts/dispatch_discussion_feedback.py b/.github/workflows/scripts/dispatch_discussion_feedback.py new file mode 100644 index 000000000..100af054a --- /dev/null +++ b/.github/workflows/scripts/dispatch_discussion_feedback.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 + +import json +import os +import socket +import sys +import time +from datetime import datetime, timezone +from urllib import error, request + + +REQUEST_TIMEOUT_SECONDS = 10 +MAX_DISPATCH_ATTEMPTS = 3 +RETRY_DELAY_SECONDS = 2 + + +def load_event_payload(event_path: str) -> dict: + with open(event_path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def parse_target_repository(value: str) -> tuple[str, str]: + owner, separator, repo = value.partition("/") + if not separator or not owner or not repo: + raise ValueError("TARGET_OPS_REPOSITORY must be set as owner/repo") + return owner, repo + + +def normalize_login(value: str) -> str: + return value.strip().lower() + + +def build_trusted_staff(raw_value: str) -> set[str]: + return {normalize_login(entry) for entry in raw_value.split(",") if entry.strip()} + + +def iso_timestamp() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def is_retryable_error(exc: Exception) -> bool: + if isinstance(exc, error.HTTPError): + return exc.code >= 500 + + if isinstance(exc, socket.timeout): + return True + + if isinstance(exc, error.URLError): + return isinstance(exc.reason, socket.timeout) + + return False + + +def create_dispatch(owner: str, repo: str, token: str, event_type: str, payload: dict) -> None: + api_url = f"https://api.github.com/repos/{owner}/{repo}/dispatches" + body = json.dumps( + { + "event_type": event_type, + "client_payload": payload, + } + ).encode("utf-8") + api_request = request.Request( + api_url, + data=body, + method="POST", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "community-discussion-feedback-dispatch", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + + last_error = None + for attempt in range(1, MAX_DISPATCH_ATTEMPTS + 1): + try: + with request.urlopen(api_request, timeout=REQUEST_TIMEOUT_SECONDS) as response: + if response.status != 204: + raise RuntimeError(f"Dispatch failed with unexpected status: {response.status}") + return + except Exception as exc: + last_error = exc + if attempt < MAX_DISPATCH_ATTEMPTS and is_retryable_error(exc): + print( + f"Dispatch attempt {attempt} failed with a transient error; retrying in {RETRY_DELAY_SECONDS} seconds..." + ) + time.sleep(RETRY_DELAY_SECONDS) + continue + break + + if isinstance(last_error, error.HTTPError): + message = last_error.read().decode("utf-8", errors="replace") + raise RuntimeError( + f"Dispatch failed with status {last_error.code}: {message}" + ) from last_error + + if isinstance(last_error, error.URLError): + raise RuntimeError(f"Dispatch failed: {last_error.reason}") from last_error + + if isinstance(last_error, socket.timeout): + raise RuntimeError("Dispatch failed: request timed out") from last_error + + if last_error is not None: + raise RuntimeError(f"Dispatch failed: {last_error}") from last_error + + +def main() -> int: + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not event_path: + raise RuntimeError("GITHUB_EVENT_PATH is not set") + + token = os.environ.get("DISPATCH_TOKEN", "") + if not token: + print("COMM_COMM_OPS_DISPATCH_TOKEN is not configured; skipping ops dispatch.") + return 0 + + payload = load_event_payload(event_path) + action = payload.get("action") + discussion = payload.get("discussion") or {} + label = payload.get("label") or {} + actor = os.environ.get("GITHUB_ACTOR") or ((payload.get("sender") or {}).get("login")) or "" + normalized_actor = normalize_login(actor) + trusted_staff = build_trusted_staff(os.environ.get("PROD_TRUSTED_STAFF", "")) + + discussion_number = discussion.get("number") + if not discussion_number: + raise RuntimeError("Missing discussion number in discussion event payload") + + actor_type = "bot" if actor.endswith("[bot]") else "human" + if actor_type == "bot": + print(f"Skipping bot-generated event: {actor}") + return 0 + + dispatch_event_type = ( + "discussion-created" if action in {"created", "category_changed"} else "label-feedback" + ) + + label_name = label.get("name") + if dispatch_event_type == "label-feedback" and not label_name: + print("Skipping label event with missing label") + return 0 + + owner, repo = parse_target_repository(os.environ.get("TARGET_OPS_REPOSITORY", "")) + source_repository = os.environ.get("GITHUB_REPOSITORY", "unknown/unknown") + dispatch_payload = { + "data": { + "source_repository": source_repository, + "origin_repo_role": "prod-truth", + "discussion_number": discussion_number, + "discussion_title": discussion.get("title") or "unknown", + "discussion_url": discussion.get("html_url") or discussion.get("url") or "", + "category": (discussion.get("category") or {}).get("name") or "unknown", + "category_slug": (discussion.get("category") or {}).get("slug") or "unknown", + "event_type": action, + "label": label_name, + "actor": actor, + "actor_type": actor_type, + "is_trusted_staff": normalized_actor in trusted_staff, + "label_source": "manual" if dispatch_event_type == "label-feedback" else "mirror-observation", + "createdAt": iso_timestamp(), + } + } + create_dispatch(owner, repo, token, dispatch_event_type, dispatch_payload) + print( + f"Forwarded {action} event as {dispatch_event_type} to ops for discussion #{discussion_number}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file