Skip to content

Commit f4b77ec

Browse files
authored
Merge pull request community#187019 from community/welcome_label
Adding new label for first time posters
2 parents fbd4e83 + f1c7ed6 commit f4b77ec

File tree

2 files changed

+389
-0
lines changed

2 files changed

+389
-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)
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
name: Welcome & Label First-Time Discussion Author
2+
3+
on:
4+
discussion:
5+
types: [created]
6+
7+
permissions:
8+
contents: read
9+
discussions: write
10+
issues: write
11+
12+
jobs:
13+
label_welcome_discussion:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.11"
21+
22+
- name: Check if GitHub employee (member of org "github")
23+
id: check_employee
24+
uses: actions/github-script@v7
25+
with:
26+
github-token: ${{ secrets.READ_GITHUB_ORG_MEMBERS_TOKEN }}
27+
result-encoding: string
28+
script: |
29+
const username = (context.payload.discussion?.user?.login || "").trim();
30+
31+
if (!username) {
32+
console.log("No username found in discussion payload; treating as not-employee.");
33+
return "false";
34+
}
35+
36+
try {
37+
const response = await github.rest.orgs.checkMembershipForUser({
38+
org: "github",
39+
username
40+
});
41+
42+
if (response.status === 204) {
43+
console.log(`'${username}' IS a member of org 'github' (treat as employee).`);
44+
return "true";
45+
}
46+
47+
console.log(`Unexpected status ${response.status}; treating as not-employee.`);
48+
return "false";
49+
} catch (error) {
50+
if (error.status === 404) {
51+
console.log(`'${username}' is NOT a member of org 'github'.`);
52+
return "false";
53+
}
54+
55+
console.log("Employee check failed; treating as not-employee.");
56+
return "false";
57+
}
58+
59+
- name: Check first-time status (Python)
60+
id: first_time
61+
if: steps.check_employee.outputs.result != 'true'
62+
shell: bash
63+
env:
64+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65+
USERNAME: ${{ github.event.discussion.user.login }}
66+
OWNER: ${{ github.repository_owner }}
67+
REPO: ${{ github.event.repository.name }}
68+
CURRENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
69+
run: |
70+
set -euo pipefail
71+
python .github/workflows/scripts/first_time_discussion_author_live.py
72+
73+
- name: Resolve Discussion ID + Label ID ("Welcome 🎉")
74+
id: ids
75+
if: steps.check_employee.outputs.result != 'true' && steps.first_time.outputs.should_welcome == 'true'
76+
uses: actions/github-script@v7
77+
env:
78+
LABEL_NAME: "Welcome 🎉"
79+
with:
80+
github-token: ${{ secrets.GITHUB_TOKEN }}
81+
result-encoding: string
82+
script: |
83+
const owner = context.repo.owner;
84+
const repo = context.repo.repo;
85+
86+
const discussionNumber = context.payload?.discussion?.number;
87+
if (!discussionNumber) {
88+
core.setFailed("Missing discussion number in event payload.");
89+
return "";
90+
}
91+
92+
const labelName = process.env.LABEL_NAME;
93+
94+
// 1) Get Discussion node ID
95+
const discussionResp = await github.graphql(
96+
`
97+
query($owner: String!, $repo: String!, $number: Int!) {
98+
repository(owner: $owner, name: $repo) {
99+
discussion(number: $number) { id number url title }
100+
}
101+
}
102+
`,
103+
{ owner, repo, number: discussionNumber }
104+
);
105+
106+
const discussionNode = discussionResp?.repository?.discussion;
107+
if (!discussionNode?.id) {
108+
throw new Error(`Unable to resolve discussion node id for #${discussionNumber}`);
109+
}
110+
111+
// 2) Paginate labels to find the correct label node
112+
let cursor = null;
113+
let foundLabel = null;
114+
115+
do {
116+
const labelsResp = await github.graphql(
117+
`
118+
query($owner: String!, $repo: String!, $after: String) {
119+
repository(owner: $owner, name: $repo) {
120+
labels(first: 100, after: $after) {
121+
pageInfo { hasNextPage endCursor }
122+
nodes { name id }
123+
}
124+
}
125+
}
126+
`,
127+
{ owner, repo, after: cursor }
128+
);
129+
130+
const labelsConnection = labelsResp?.repository?.labels;
131+
const labels = labelsConnection?.nodes || [];
132+
133+
foundLabel = labels.find(l => l?.name === labelName);
134+
135+
const pageInfo = labelsConnection?.pageInfo;
136+
cursor = pageInfo?.hasNextPage ? pageInfo.endCursor : null;
137+
} while (!foundLabel && cursor);
138+
139+
if (!foundLabel) {
140+
throw new Error(`Label "${labelName}" not found in ${owner}/${repo} (checked all pages)`);
141+
}
142+
143+
core.setOutput("discussion_id", discussionNode.id);
144+
core.setOutput("label_id", foundLabel.id);
145+
146+
return "ok";
147+
148+
- name: Apply label via GraphQL mutation
149+
if: steps.check_employee.outputs.result != 'true' && steps.first_time.outputs.should_welcome == 'true'
150+
env:
151+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
152+
shell: bash
153+
run: |
154+
set -euo pipefail
155+
echo "Applying label via GraphQL mutation..."
156+
gh api graphql -f query='
157+
mutation($labelableId:ID!,$labelIds:[ID!]!) {
158+
addLabelsToLabelable(input:{labelableId:$labelableId,labelIds:$labelIds}) {
159+
labelable {
160+
... on Discussion {
161+
number
162+
title
163+
labels(first:10) { nodes { name } }
164+
}
165+
}
166+
}
167+
}
168+
' \
169+
-F labelableId='${{ steps.ids.outputs.discussion_id }}' \
170+
-F labelIds[]='${{ steps.ids.outputs.label_id }}'

0 commit comments

Comments
 (0)