Skip to content

Commit f1c7ed6

Browse files
authored
Add script to check for first-time discussion authors
1 parent 9dd5e06 commit f1c7ed6

File tree

1 file changed

+219
-0
lines changed

1 file changed

+219
-0
lines changed
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import json
2+
import os
3+
import sys
4+
import urllib.request
5+
import urllib.error
6+
import urllib.parse
7+
8+
9+
def require_env(name: str) -> str:
10+
value = os.environ.get(name, "")
11+
if not value.strip():
12+
raise RuntimeError(f"Missing required env var: {name}")
13+
return value.strip()
14+
15+
16+
def github_api_request(url: str, token: str, method: str = "GET", body: dict | None = None) -> dict:
17+
headers = {
18+
"Authorization": f"Bearer {token}",
19+
"Accept": "application/vnd.github+json",
20+
"User-Agent": "first-time-discussion-author-check",
21+
}
22+
23+
data = None
24+
if body is not None:
25+
data = json.dumps(body).encode("utf-8")
26+
headers["Content-Type"] = "application/json"
27+
28+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
29+
30+
try:
31+
with urllib.request.urlopen(req) as resp:
32+
payload = resp.read().decode("utf-8")
33+
return json.loads(payload) if payload else {}
34+
except urllib.error.HTTPError as e:
35+
raw = e.read().decode("utf-8") if e.fp else ""
36+
try:
37+
parsed = json.loads(raw) if raw else {}
38+
except Exception:
39+
parsed = {"message": raw or str(e)}
40+
parsed["_http_status"] = e.code
41+
raise urllib.error.HTTPError(e.url, e.code, e.msg, e.hdrs, None) from RuntimeError(json.dumps(parsed))
42+
43+
44+
def graphql_search_discussions(token: str, owner: str, repo: str, username: str) -> dict:
45+
url = "https://api.github.com/graphql"
46+
query = (
47+
"query($q: String!) {"
48+
" search(query: $q, type: DISCUSSION, first: 2) {"
49+
" discussionCount"
50+
" nodes { ... on Discussion { number url title } }"
51+
" }"
52+
"}"
53+
)
54+
search_q = f"repo:{owner}/{repo} author:{username}"
55+
body = {"query": query, "variables": {"q": search_q}}
56+
return github_api_request(url, token, method="POST", body=body)
57+
58+
59+
def rest_search_discussions(token: str, owner: str, repo: str, username: str) -> dict:
60+
# Search issues endpoint covers discussions via type:discussions
61+
q = f"repo:{owner}/{repo} type:discussions author:{username}"
62+
url = "https://api.github.com/search/issues?q=" + urllib.parse.quote(q) + "&per_page=1"
63+
return github_api_request(url, token, method="GET")
64+
65+
66+
def write_output(name: str, value: str) -> None:
67+
github_output = os.environ.get("GITHUB_OUTPUT")
68+
if not github_output:
69+
# Local/dev fallback
70+
print(f"::notice::OUTPUT {name}={value}")
71+
return
72+
with open(github_output, "a", encoding="utf-8") as f:
73+
f.write(f"{name}={value}\n")
74+
75+
76+
def main() -> int:
77+
token = require_env("GITHUB_TOKEN")
78+
79+
# This script is intended to run only for discussion.created in the CURRENT repo.
80+
username = require_env("USERNAME")
81+
owner = require_env("OWNER")
82+
repo = require_env("REPO")
83+
84+
current_discussion_number_raw = require_env("CURRENT_DISCUSSION_NUMBER")
85+
current_discussion_number = int(current_discussion_number_raw)
86+
87+
print("=== DEBUG (inputs) ===")
88+
print("owner/repo:", f"{owner}/{repo}")
89+
print("username:", username)
90+
print("current_discussion_number:", current_discussion_number_raw)
91+
print("======================")
92+
93+
# Defaults
94+
should_welcome = "false"
95+
status = "inconclusive"
96+
reason = ""
97+
98+
# 1) GraphQL search once
99+
try:
100+
gql = graphql_search_discussions(token, owner, repo, username)
101+
except urllib.error.HTTPError as e:
102+
# Unpack the RuntimeError from the cause if present
103+
print("GraphQL request failed.")
104+
print("HTTP status:", getattr(e, "code", "unknown"))
105+
if e.__cause__:
106+
print("cause:", str(e.__cause__))
107+
108+
status = "error"
109+
reason = "graphql_error"
110+
write_output("should_welcome", should_welcome)
111+
write_output("status", status)
112+
write_output("reason", reason)
113+
return 0
114+
115+
if "errors" in gql and gql["errors"]:
116+
print("GraphQL error response:")
117+
print(json.dumps(gql, indent=2))
118+
119+
status = "error"
120+
reason = "graphql_error_response"
121+
write_output("should_welcome", should_welcome)
122+
write_output("status", status)
123+
write_output("reason", reason)
124+
return 0
125+
126+
discussion_count = int(gql["data"]["search"].get("discussionCount") or 0)
127+
nodes = gql["data"]["search"]["nodes"]
128+
129+
print("=== DEBUG (GraphQL response) ===")
130+
print("discussionCount:", discussion_count)
131+
for index, node in enumerate(nodes, start=1):
132+
print(f"GraphQL hit #{index}: #{node['number']} {node['url']}")
133+
print("===============================")
134+
135+
# Discussion-created logic:
136+
# - If we see 2+ discussions, they are not first-time.
137+
# - If we see exactly 1 discussion and it's the current discussion, they are first-time.
138+
# - If we see 0, fall back to REST.
139+
140+
if discussion_count >= 2:
141+
write_output("should_welcome", "false")
142+
print("Prior discussions found")
143+
return 0
144+
145+
elif discussion_count == 1:
146+
# assumes nodes[0] exists and has "number"
147+
if nodes[0]["number"] == current_discussion_number:
148+
write_output("should_welcome", "true")
149+
print("Only current discussion found")
150+
return 0
151+
152+
else:
153+
write_output("should_welcome", "false")
154+
print("Single discussion but not current")
155+
return 0
156+
157+
else:
158+
# discussion_count == 0 => fall back to REST below
159+
pass
160+
161+
# 2) REST fallback for diagnostic/private/unsearchable handling
162+
print("GraphQL returned 0; falling back to REST search for diagnostic signal...")
163+
try:
164+
rest = rest_search_discussions(token, owner, repo, username)
165+
except urllib.error.HTTPError as e:
166+
print("=== DEBUG (REST error) ===")
167+
print("HTTP status:", getattr(e, "code", "unknown"))
168+
if e.__cause__:
169+
cause_text = str(e.__cause__)
170+
print("cause:", cause_text)
171+
try:
172+
parsed = json.loads(cause_text)
173+
except Exception:
174+
parsed = {}
175+
176+
# Detect the specific 422 validation failure: "cannot be searched..."
177+
if parsed.get("_http_status") == 422 and str(parsed.get("message", "")).lower() == "validation failed":
178+
errors = parsed.get("errors") or []
179+
first_message = (errors[0].get("message") if errors else "") or ""
180+
if "cannot be searched" in first_message.lower():
181+
print("RESULT: SKIP — user appears unsearchable (private/staff/hidden).")
182+
write_output("should_welcome", "false")
183+
write_output("status", "skip")
184+
return 0
185+
186+
# Otherwise: inconclusive error
187+
write_output("should_welcome", "false")
188+
write_output("status", "inconclusive")
189+
write_output("reason", "rest_search_error")
190+
return 0
191+
192+
total_count = int(rest.get("total_count") or 0)
193+
print("=== DEBUG (REST response) ===")
194+
print("total_count:", total_count)
195+
print("=============================")
196+
197+
if total_count > 0:
198+
write_output("should_welcome", "false")
199+
write_output("status", "conclusive")
200+
write_output("reason", "prior_discussions_found_rest")
201+
return 0
202+
203+
# Still ambiguous
204+
write_output("should_welcome", "false")
205+
write_output("status", "inconclusive")
206+
write_output("reason", "graphql_and_rest_zero")
207+
return 0
208+
209+
210+
if __name__ == "__main__":
211+
try:
212+
sys.exit(main())
213+
except Exception as exc:
214+
# Fail-safe: never welcome on crash, but surface error in logs.
215+
print("ERROR:", str(exc))
216+
write_output("should_welcome", "false")
217+
write_output("status", "error")
218+
write_output("reason", "script_crash")
219+
sys.exit(0)

0 commit comments

Comments
 (0)