From 9dd5e061f174f422bf74f32ca83f3960103a5b16 Mon Sep 17 00:00:00 2001 From: Ashley Nicolson Date: Thu, 12 Feb 2026 18:15:39 +0000 Subject: [PATCH 1/2] Add workflow to welcome first-time discussion authors --- ...come_first_time_discussion_author_live.yml | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 .github/workflows/welcome_first_time_discussion_author_live.yml diff --git a/.github/workflows/welcome_first_time_discussion_author_live.yml b/.github/workflows/welcome_first_time_discussion_author_live.yml new file mode 100644 index 000000000..0c5d30c4b --- /dev/null +++ b/.github/workflows/welcome_first_time_discussion_author_live.yml @@ -0,0 +1,170 @@ +name: Welcome & Label First-Time Discussion Author + +on: + discussion: + types: [created] + +permissions: + contents: read + discussions: write + issues: write + +jobs: + label_welcome_discussion: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Check if GitHub employee (member of org "github") + id: check_employee + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.READ_GITHUB_ORG_MEMBERS_TOKEN }} + result-encoding: string + script: | + const username = (context.payload.discussion?.user?.login || "").trim(); + + if (!username) { + console.log("No username found in discussion payload; treating as not-employee."); + return "false"; + } + + try { + const response = await github.rest.orgs.checkMembershipForUser({ + org: "github", + username + }); + + if (response.status === 204) { + console.log(`'${username}' IS a member of org 'github' (treat as employee).`); + return "true"; + } + + console.log(`Unexpected status ${response.status}; treating as not-employee.`); + return "false"; + } catch (error) { + if (error.status === 404) { + console.log(`'${username}' is NOT a member of org 'github'.`); + return "false"; + } + + console.log("Employee check failed; treating as not-employee."); + return "false"; + } + + - name: Check first-time status (Python) + id: first_time + if: steps.check_employee.outputs.result != 'true' + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + USERNAME: ${{ github.event.discussion.user.login }} + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.event.repository.name }} + CURRENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + run: | + set -euo pipefail + python .github/workflows/scripts/first_time_discussion_author_live.py + + - name: Resolve Discussion ID + Label ID ("Welcome 🎉") + id: ids + if: steps.check_employee.outputs.result != 'true' && steps.first_time.outputs.should_welcome == 'true' + uses: actions/github-script@v7 + env: + LABEL_NAME: "Welcome 🎉" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + result-encoding: string + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + + const discussionNumber = context.payload?.discussion?.number; + if (!discussionNumber) { + core.setFailed("Missing discussion number in event payload."); + return ""; + } + + const labelName = process.env.LABEL_NAME; + + // 1) Get Discussion node ID + const discussionResp = await github.graphql( + ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { id number url title } + } + } + `, + { owner, repo, number: discussionNumber } + ); + + const discussionNode = discussionResp?.repository?.discussion; + if (!discussionNode?.id) { + throw new Error(`Unable to resolve discussion node id for #${discussionNumber}`); + } + + // 2) Paginate labels to find the correct label node + let cursor = null; + let foundLabel = null; + + do { + const labelsResp = await github.graphql( + ` + query($owner: String!, $repo: String!, $after: String) { + repository(owner: $owner, name: $repo) { + labels(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { name id } + } + } + } + `, + { owner, repo, after: cursor } + ); + + const labelsConnection = labelsResp?.repository?.labels; + const labels = labelsConnection?.nodes || []; + + foundLabel = labels.find(l => l?.name === labelName); + + const pageInfo = labelsConnection?.pageInfo; + cursor = pageInfo?.hasNextPage ? pageInfo.endCursor : null; + } while (!foundLabel && cursor); + + if (!foundLabel) { + throw new Error(`Label "${labelName}" not found in ${owner}/${repo} (checked all pages)`); + } + + core.setOutput("discussion_id", discussionNode.id); + core.setOutput("label_id", foundLabel.id); + + return "ok"; + + - name: Apply label via GraphQL mutation + if: steps.check_employee.outputs.result != 'true' && steps.first_time.outputs.should_welcome == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + echo "Applying label via GraphQL mutation..." + gh api graphql -f query=' + mutation($labelableId:ID!,$labelIds:[ID!]!) { + addLabelsToLabelable(input:{labelableId:$labelableId,labelIds:$labelIds}) { + labelable { + ... on Discussion { + number + title + labels(first:10) { nodes { name } } + } + } + } + } + ' \ + -F labelableId='${{ steps.ids.outputs.discussion_id }}' \ + -F labelIds[]='${{ steps.ids.outputs.label_id }}' From f1c7ed67220dc3d4a6b22191dcf9e402f35cddcc Mon Sep 17 00:00:00 2001 From: Ashley Nicolson Date: Thu, 12 Feb 2026 18:17:48 +0000 Subject: [PATCH 2/2] Add script to check for first-time discussion authors --- .../first_time_discussion_author_live.py | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 .github/workflows/scripts/first_time_discussion_author_live.py diff --git a/.github/workflows/scripts/first_time_discussion_author_live.py b/.github/workflows/scripts/first_time_discussion_author_live.py new file mode 100644 index 000000000..6b9166c91 --- /dev/null +++ b/.github/workflows/scripts/first_time_discussion_author_live.py @@ -0,0 +1,219 @@ +import json +import os +import sys +import urllib.request +import urllib.error +import urllib.parse + + +def require_env(name: str) -> str: + value = os.environ.get(name, "") + if not value.strip(): + raise RuntimeError(f"Missing required env var: {name}") + return value.strip() + + +def github_api_request(url: str, token: str, method: str = "GET", body: dict | None = None) -> dict: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "User-Agent": "first-time-discussion-author-check", + } + + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + + try: + with urllib.request.urlopen(req) as resp: + payload = resp.read().decode("utf-8") + return json.loads(payload) if payload else {} + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8") if e.fp else "" + try: + parsed = json.loads(raw) if raw else {} + except Exception: + parsed = {"message": raw or str(e)} + parsed["_http_status"] = e.code + raise urllib.error.HTTPError(e.url, e.code, e.msg, e.hdrs, None) from RuntimeError(json.dumps(parsed)) + + +def graphql_search_discussions(token: str, owner: str, repo: str, username: str) -> dict: + url = "https://api.github.com/graphql" + query = ( + "query($q: String!) {" + " search(query: $q, type: DISCUSSION, first: 2) {" + " discussionCount" + " nodes { ... on Discussion { number url title } }" + " }" + "}" + ) + search_q = f"repo:{owner}/{repo} author:{username}" + body = {"query": query, "variables": {"q": search_q}} + return github_api_request(url, token, method="POST", body=body) + + +def rest_search_discussions(token: str, owner: str, repo: str, username: str) -> dict: + # Search issues endpoint covers discussions via type:discussions + q = f"repo:{owner}/{repo} type:discussions author:{username}" + url = "https://api.github.com/search/issues?q=" + urllib.parse.quote(q) + "&per_page=1" + return github_api_request(url, token, method="GET") + + +def write_output(name: str, value: str) -> None: + github_output = os.environ.get("GITHUB_OUTPUT") + if not github_output: + # Local/dev fallback + print(f"::notice::OUTPUT {name}={value}") + return + with open(github_output, "a", encoding="utf-8") as f: + f.write(f"{name}={value}\n") + + +def main() -> int: + token = require_env("GITHUB_TOKEN") + + # This script is intended to run only for discussion.created in the CURRENT repo. + username = require_env("USERNAME") + owner = require_env("OWNER") + repo = require_env("REPO") + + current_discussion_number_raw = require_env("CURRENT_DISCUSSION_NUMBER") + current_discussion_number = int(current_discussion_number_raw) + + print("=== DEBUG (inputs) ===") + print("owner/repo:", f"{owner}/{repo}") + print("username:", username) + print("current_discussion_number:", current_discussion_number_raw) + print("======================") + + # Defaults + should_welcome = "false" + status = "inconclusive" + reason = "" + + # 1) GraphQL search once + try: + gql = graphql_search_discussions(token, owner, repo, username) + except urllib.error.HTTPError as e: + # Unpack the RuntimeError from the cause if present + print("GraphQL request failed.") + print("HTTP status:", getattr(e, "code", "unknown")) + if e.__cause__: + print("cause:", str(e.__cause__)) + + status = "error" + reason = "graphql_error" + write_output("should_welcome", should_welcome) + write_output("status", status) + write_output("reason", reason) + return 0 + + if "errors" in gql and gql["errors"]: + print("GraphQL error response:") + print(json.dumps(gql, indent=2)) + + status = "error" + reason = "graphql_error_response" + write_output("should_welcome", should_welcome) + write_output("status", status) + write_output("reason", reason) + return 0 + + discussion_count = int(gql["data"]["search"].get("discussionCount") or 0) + nodes = gql["data"]["search"]["nodes"] + + print("=== DEBUG (GraphQL response) ===") + print("discussionCount:", discussion_count) + for index, node in enumerate(nodes, start=1): + print(f"GraphQL hit #{index}: #{node['number']} {node['url']}") + print("===============================") + + # Discussion-created logic: + # - If we see 2+ discussions, they are not first-time. + # - If we see exactly 1 discussion and it's the current discussion, they are first-time. + # - If we see 0, fall back to REST. + + if discussion_count >= 2: + write_output("should_welcome", "false") + print("Prior discussions found") + return 0 + + elif discussion_count == 1: + # assumes nodes[0] exists and has "number" + if nodes[0]["number"] == current_discussion_number: + write_output("should_welcome", "true") + print("Only current discussion found") + return 0 + + else: + write_output("should_welcome", "false") + print("Single discussion but not current") + return 0 + + else: + # discussion_count == 0 => fall back to REST below + pass + + # 2) REST fallback for diagnostic/private/unsearchable handling + print("GraphQL returned 0; falling back to REST search for diagnostic signal...") + try: + rest = rest_search_discussions(token, owner, repo, username) + except urllib.error.HTTPError as e: + print("=== DEBUG (REST error) ===") + print("HTTP status:", getattr(e, "code", "unknown")) + if e.__cause__: + cause_text = str(e.__cause__) + print("cause:", cause_text) + try: + parsed = json.loads(cause_text) + except Exception: + parsed = {} + + # Detect the specific 422 validation failure: "cannot be searched..." + if parsed.get("_http_status") == 422 and str(parsed.get("message", "")).lower() == "validation failed": + errors = parsed.get("errors") or [] + first_message = (errors[0].get("message") if errors else "") or "" + if "cannot be searched" in first_message.lower(): + print("RESULT: SKIP — user appears unsearchable (private/staff/hidden).") + write_output("should_welcome", "false") + write_output("status", "skip") + return 0 + + # Otherwise: inconclusive error + write_output("should_welcome", "false") + write_output("status", "inconclusive") + write_output("reason", "rest_search_error") + return 0 + + total_count = int(rest.get("total_count") or 0) + print("=== DEBUG (REST response) ===") + print("total_count:", total_count) + print("=============================") + + if total_count > 0: + write_output("should_welcome", "false") + write_output("status", "conclusive") + write_output("reason", "prior_discussions_found_rest") + return 0 + + # Still ambiguous + write_output("should_welcome", "false") + write_output("status", "inconclusive") + write_output("reason", "graphql_and_rest_zero") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: + # Fail-safe: never welcome on crash, but surface error in logs. + print("ERROR:", str(exc)) + write_output("should_welcome", "false") + write_output("status", "error") + write_output("reason", "script_crash") + sys.exit(0)