Skip to content

Commit 7d09970

Browse files
authored
refactor: discussion feedback workflow and add dispatch script (community#197071)
* Refactor discussion feedback workflow and add dispatch script * Enhance dispatch error handling and normalize actor login in feedback workflow
1 parent 0de64c5 commit 7d09970

2 files changed

Lines changed: 188 additions & 48 deletions

File tree

Lines changed: 16 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,32 @@
1-
name: Dispatch Staff Label Feedback
1+
name: Dispatch Discussion Feedback
22

33
on:
44
discussion:
55
types:
6+
- created
7+
- category_changed
68
- labeled
79
- unlabeled
810

911
permissions:
1012
contents: read
1113

1214
jobs:
13-
dispatch-feedback-event:
15+
dispatch-ops-feedback-event:
1416
runs-on: ubuntu-latest
1517
env:
16-
COMMUNITY_OPS_REPOSITORY: community/community-ops
18+
TARGET_OPS_REPOSITORY: githubnext/aw-community-ops
19+
DISPATCH_TOKEN: ${{ secrets.COMM_COMM_OPS_DISPATCH_TOKEN }}
20+
PROD_TRUSTED_STAFF: ${{ vars.PROD_TRUSTED_STAFF }}
1721
steps:
18-
- name: Forward discussion label events
19-
uses: actions/github-script@v8
20-
with:
21-
github-token: ${{ secrets.WRITE_TO_COMM_OPS_TOKEN }}
22-
script: |
23-
const actor = context.actor;
24-
const action = context.payload.action;
25-
const discussion = context.payload.discussion;
26-
const label = context.payload.label;
22+
- name: Checkout repository
23+
if: env.DISPATCH_TOKEN != ''
24+
uses: actions/checkout@v4
2725

28-
if (!label?.name) {
29-
core.info("Skipping event with missing label");
30-
return;
31-
}
26+
- name: Forward production discussion facts to ops
27+
if: env.DISPATCH_TOKEN != ''
28+
run: python3 .github/workflows/scripts/dispatch_discussion_feedback.py
3229

33-
if (!discussion?.number) {
34-
throw new Error("Missing discussion number in discussion event payload");
35-
}
36-
37-
if ((actor || "").endsWith("[bot]")) {
38-
core.info(`Skipping bot-generated event: ${actor}`);
39-
return;
40-
}
41-
42-
const [owner, repo] = process.env.COMMUNITY_OPS_REPOSITORY.split("/");
43-
await github.rest.repos.createDispatchEvent({
44-
owner,
45-
repo,
46-
event_type: "staff-label-correction",
47-
client_payload: {
48-
data: {
49-
source_repository: `${context.repo.owner}/${context.repo.repo}`,
50-
target_repository: `${process.env.COMMUNITY_OPS_REPOSITORY}`,
51-
discussion_number: discussion.number,
52-
discussion_title: discussion.title || "unknown",
53-
discussion_url: discussion.html_url || discussion.url || "",
54-
category: discussion.category?.name || "unknown",
55-
category_slug: discussion.category?.slug || "unknown",
56-
event_type: action,
57-
label: label.name,
58-
actor,
59-
createdAt: new Date().toISOString(),
60-
},
61-
},
62-
});
63-
64-
core.info(`Forwarded ${action} event for discussion #${discussion.number}`);
30+
- name: Note missing dispatch token
31+
if: env.DISPATCH_TOKEN == ''
32+
run: echo "COMM_COMM_OPS_DISPATCH_TOKEN is not configured; skipping ops dispatch."
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import os
5+
import socket
6+
import sys
7+
import time
8+
from datetime import datetime, timezone
9+
from urllib import error, request
10+
11+
12+
REQUEST_TIMEOUT_SECONDS = 10
13+
MAX_DISPATCH_ATTEMPTS = 3
14+
RETRY_DELAY_SECONDS = 2
15+
16+
17+
def load_event_payload(event_path: str) -> dict:
18+
with open(event_path, "r", encoding="utf-8") as handle:
19+
return json.load(handle)
20+
21+
22+
def parse_target_repository(value: str) -> tuple[str, str]:
23+
owner, separator, repo = value.partition("/")
24+
if not separator or not owner or not repo:
25+
raise ValueError("TARGET_OPS_REPOSITORY must be set as owner/repo")
26+
return owner, repo
27+
28+
29+
def normalize_login(value: str) -> str:
30+
return value.strip().lower()
31+
32+
33+
def build_trusted_staff(raw_value: str) -> set[str]:
34+
return {normalize_login(entry) for entry in raw_value.split(",") if entry.strip()}
35+
36+
37+
def iso_timestamp() -> str:
38+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
39+
40+
41+
def is_retryable_error(exc: Exception) -> bool:
42+
if isinstance(exc, error.HTTPError):
43+
return exc.code >= 500
44+
45+
if isinstance(exc, socket.timeout):
46+
return True
47+
48+
if isinstance(exc, error.URLError):
49+
return isinstance(exc.reason, socket.timeout)
50+
51+
return False
52+
53+
54+
def create_dispatch(owner: str, repo: str, token: str, event_type: str, payload: dict) -> None:
55+
api_url = f"https://api.github.com/repos/{owner}/{repo}/dispatches"
56+
body = json.dumps(
57+
{
58+
"event_type": event_type,
59+
"client_payload": payload,
60+
}
61+
).encode("utf-8")
62+
api_request = request.Request(
63+
api_url,
64+
data=body,
65+
method="POST",
66+
headers={
67+
"Accept": "application/vnd.github+json",
68+
"Authorization": f"Bearer {token}",
69+
"Content-Type": "application/json",
70+
"User-Agent": "community-discussion-feedback-dispatch",
71+
"X-GitHub-Api-Version": "2022-11-28",
72+
},
73+
)
74+
75+
last_error = None
76+
for attempt in range(1, MAX_DISPATCH_ATTEMPTS + 1):
77+
try:
78+
with request.urlopen(api_request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
79+
if response.status != 204:
80+
raise RuntimeError(f"Dispatch failed with unexpected status: {response.status}")
81+
return
82+
except Exception as exc:
83+
last_error = exc
84+
if attempt < MAX_DISPATCH_ATTEMPTS and is_retryable_error(exc):
85+
print(
86+
f"Dispatch attempt {attempt} failed with a transient error; retrying in {RETRY_DELAY_SECONDS} seconds..."
87+
)
88+
time.sleep(RETRY_DELAY_SECONDS)
89+
continue
90+
break
91+
92+
if isinstance(last_error, error.HTTPError):
93+
message = last_error.read().decode("utf-8", errors="replace")
94+
raise RuntimeError(
95+
f"Dispatch failed with status {last_error.code}: {message}"
96+
) from last_error
97+
98+
if isinstance(last_error, error.URLError):
99+
raise RuntimeError(f"Dispatch failed: {last_error.reason}") from last_error
100+
101+
if isinstance(last_error, socket.timeout):
102+
raise RuntimeError("Dispatch failed: request timed out") from last_error
103+
104+
if last_error is not None:
105+
raise RuntimeError(f"Dispatch failed: {last_error}") from last_error
106+
107+
108+
def main() -> int:
109+
event_path = os.environ.get("GITHUB_EVENT_PATH")
110+
if not event_path:
111+
raise RuntimeError("GITHUB_EVENT_PATH is not set")
112+
113+
token = os.environ.get("DISPATCH_TOKEN", "")
114+
if not token:
115+
print("COMM_COMM_OPS_DISPATCH_TOKEN is not configured; skipping ops dispatch.")
116+
return 0
117+
118+
payload = load_event_payload(event_path)
119+
action = payload.get("action")
120+
discussion = payload.get("discussion") or {}
121+
label = payload.get("label") or {}
122+
actor = os.environ.get("GITHUB_ACTOR") or ((payload.get("sender") or {}).get("login")) or ""
123+
normalized_actor = normalize_login(actor)
124+
trusted_staff = build_trusted_staff(os.environ.get("PROD_TRUSTED_STAFF", ""))
125+
126+
discussion_number = discussion.get("number")
127+
if not discussion_number:
128+
raise RuntimeError("Missing discussion number in discussion event payload")
129+
130+
actor_type = "bot" if actor.endswith("[bot]") else "human"
131+
if actor_type == "bot":
132+
print(f"Skipping bot-generated event: {actor}")
133+
return 0
134+
135+
dispatch_event_type = (
136+
"discussion-created" if action in {"created", "category_changed"} else "label-feedback"
137+
)
138+
139+
label_name = label.get("name")
140+
if dispatch_event_type == "label-feedback" and not label_name:
141+
print("Skipping label event with missing label")
142+
return 0
143+
144+
owner, repo = parse_target_repository(os.environ.get("TARGET_OPS_REPOSITORY", ""))
145+
source_repository = os.environ.get("GITHUB_REPOSITORY", "unknown/unknown")
146+
dispatch_payload = {
147+
"data": {
148+
"source_repository": source_repository,
149+
"origin_repo_role": "prod-truth",
150+
"discussion_number": discussion_number,
151+
"discussion_title": discussion.get("title") or "unknown",
152+
"discussion_url": discussion.get("html_url") or discussion.get("url") or "",
153+
"category": (discussion.get("category") or {}).get("name") or "unknown",
154+
"category_slug": (discussion.get("category") or {}).get("slug") or "unknown",
155+
"event_type": action,
156+
"label": label_name,
157+
"actor": actor,
158+
"actor_type": actor_type,
159+
"is_trusted_staff": normalized_actor in trusted_staff,
160+
"label_source": "manual" if dispatch_event_type == "label-feedback" else "mirror-observation",
161+
"createdAt": iso_timestamp(),
162+
}
163+
}
164+
create_dispatch(owner, repo, token, dispatch_event_type, dispatch_payload)
165+
print(
166+
f"Forwarded {action} event as {dispatch_event_type} to ops for discussion #{discussion_number}"
167+
)
168+
return 0
169+
170+
171+
if __name__ == "__main__":
172+
sys.exit(main())

0 commit comments

Comments
 (0)