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