1- name : " Ambassador comments (decisions only) "
1+ name : " Ambassador Accepted: Post + Export "
22
33on :
44 workflow_dispatch :
55 inputs :
6- private_repo :
7- description : " Private repo containing the CSVs (owner/name) "
6+ label_name :
7+ description : " Target label "
88 required : true
9- default : pytorch-fdn/ambassador-program-management
9+ default : Accepted
1010 type : string
11- private_ref :
12- description : " Branch/tag/sha of the private repo"
13- required : true
14- default : main
15- type : string
16- decision_csv_path :
17- description : " Path to decision CSV (Submission ID, Decision, Candidate)"
18- required : true
19- default : .github/ISSUE_TEMPLATE/dmsap.csv
20- type : string
21- unlock_locked_issues :
22- description : " Temporarily unlock locked issues to post, then re-lock"
23- required : true
24- default : true
25- type : boolean
2611 dry_run :
2712 description : " Preview without posting"
2813 required : true
@@ -33,218 +18,177 @@ permissions:
3318 contents : read
3419 issues : write
3520
36- concurrency :
37- group : ambassador-comments
38- cancel-in-progress : true
39-
4021jobs :
41- comment :
22+ post-and-export :
4223 runs-on : ubuntu-latest
4324 steps :
44- - name : Checkout PUBLIC repo
25+ - name : Checkout repo
4526 uses : actions/checkout@v4
4627
47- - name : Checkout PRIVATE repo
48- uses : actions/checkout@v4
49- with :
50- repository : ${{ inputs.private_repo }}
51- ref : ${{ inputs.private_ref }}
52- token : ${{ secrets.PRIVATE_REPO_TOKEN }}
53- path : private-src
54-
5528 - name : Set up Python
5629 uses : actions/setup-python@v5
5730 with :
5831 python-version : " 3.11"
5932
60- - name : Install dependencies
33+ - name : Install deps
6134 run : pip install requests
6235
63- - name : Post comments from decision.csv
36+ - name : Post message and export CSV
6437 env :
6538 GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
6639 REPO_FULL : ${{ github.repository }}
67- DEC_CSV_ABS : ${{ github.workspace }}/private-src/${{ inputs.decision_csv_path }}
68- UNLOCK_LOCKED : ${{ inputs.unlock_locked_issues }}
40+ LABEL_NAME : ${{ inputs.label_name }}
6941 DRY_RUN : ${{ inputs.dry_run }}
7042 run : |
7143 python - <<'PY'
72- import os, csv, re, sys, urllib.parse
73- import requests
44+ import os, re, csv, sys, requests
7445 from requests.adapters import HTTPAdapter, Retry
7546
7647 token = os.getenv("GITHUB_TOKEN")
7748 repo_full = os.getenv("REPO_FULL")
78- dec_csv = os.getenv("DEC_CSV_ABS")
79- dry_run = (os.getenv("DRY_RUN","false").lower() == "true")
80- UNLOCK_LOCKED = (os.getenv("UNLOCK_LOCKED","false").lower() == "true")
49+ label_name = os.getenv("LABEL_NAME","Accepted")
50+ dry_run = (os.getenv("DRY_RUN","false").lower()=="true")
8151
8252 if not token or not repo_full:
8353 print("Missing GITHUB_TOKEN or REPO_FULL", file=sys.stderr); sys.exit(1)
54+ owner, repo = repo_full.split("/",1)
8455
85- owner, repo = repo_full.split("/", 1)
86-
87- # session with retries
8856 s = requests.Session()
89- retries = Retry(total=8, backoff_factor=0.7,
90- status_forcelist=[429,500,502,503,504],
91- allowed_methods=["GET","POST","PATCH","DELETE","HEAD","OPTIONS"])
92- s.mount("https://", HTTPAdapter(max_retries=retries))
93- s.headers.update({"Authorization": f"Bearer {token}",
94- "Accept": "application/vnd.github+json",
95- "X-GitHub-Api-Version": "2022-11-28"})
96-
97- def norm_key(x): return re.sub(r"[^a-z0-9]+","",(x or "").strip().lower())
98-
99- def get_issue_labels(num):
100- r = s.get(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/labels?per_page=100")
101- if r.status_code==200: return {x["name"] for x in r.json()}
102- print(f"Warn: get labels #{num} failed: {r.status_code} {r.text}"); return set()
103-
104- def is_locked(num):
105- r = s.get(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}")
106- if r.status_code == 200:
107- j = r.json()
108- return bool(j.get("locked")), j.get("active_lock_reason")
109- print(f"Warn: GET issue #{num} failed: {r.status_code} {r.text}")
110- return False, None
111-
112- def unlock_issue(num):
113- if dry_run:
114- print(f"DRY-RUN: would UNLOCK issue #{num}")
115- return True
116- r = s.delete(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/lock")
117- if r.status_code in (200,204):
118- return True
119- print(f"Warn: UNLOCK failed for #{num}: {r.status_code} {r.text}")
120- return False
121-
122- def relock_issue(num, reason):
123- if dry_run:
124- print(f"DRY-RUN: would RE-LOCK issue #{num} (reason={reason or 'none'})")
125- return True
126- payload = {"lock_reason": reason} if reason else {}
127- r = s.put(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/lock", json=payload)
128- if r.status_code in (200,204):
129- return True
130- print(f"Warn: RE-LOCK failed for #{num}: {r.status_code} {r.text}")
131- return False
132-
133- def add_comment_once(num, body, marker):
134- # 1) avoid duplicate comments using hidden marker
135- url = f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/comments?per_page=100"
57+ s.headers.update({
58+ "Authorization": f"Bearer {token}",
59+ "Accept": "application/vnd.github+json",
60+ "X-GitHub-Api-Version": "2022-11-28"
61+ })
62+ s.mount("https://", HTTPAdapter(max_retries=Retry(
63+ total=8, backoff_factor=0.7,
64+ status_forcelist=[429,500,502,503,504],
65+ allowed_methods=["GET","POST","HEAD","OPTIONS"]
66+ )))
67+
68+ MESSAGE_TEMPLATE = """Hello {first_name}
69+
70+ You’ve been invited to join the private **Ambassador Program Management Repository** :
71+ 👉 [pytorch-fdn/ambassador-program-management](https://github.com/pytorch-fdn/ambassador-program-management/)
72+
73+ Once you accept the invitation, you’ll find important information in the discussion thread — including instructions to submit a new issue using the **“Ambassador Confirmation & Details”** template.
74+
75+ To confirm your participation in the 2025 PyTorch Ambassador Program, please open a new issue using the **“Ambassador Confirmation & Details”** template here :
76+ 👉 [Create your confirmation issue](https://github.com/pytorch-fdn/ambassador-program-management/issues/new/choose)
77+
78+ _Completing this step will confirm your role and get you ready for onboarding 🚀_
79+
80+ As part of onboarding, we’ll be hosting a **Kick-Off Call** where we’ll share important program details, expectations, and next steps :
81+
82+ 📅 **Date:** Monday, September 8
83+ 🕒 **Time:** 5:30 PM CEST / 8:30 AM PDT
84+
85+ Once you confirm your role, you will also receive a calendar invite with the meeting link.
86+
87+ We’re truly excited to welcome you into the PyTorch Ambassador Program and look forward to the impact you’ll continue to make in the community.
88+
89+ Best regards,
90+ The PyTorch PMO
91+ " " "
92+
93+ # ---------- Helpers ----------
94+ def get_accepted_open_issues():
95+ url = f" https://api.github.com/repos/{owner}/{repo}/issues"
96+ params = {"state":"open", "labels":label_name, "per_page":100}
13697 while url :
137- r = s.get(url)
138- if r.status_code!=200:
139- print(f"Warn: list comments #{num} failed: {r.status_code} {r.text}")
140- break
141- if any(marker in (c.get("body") or "") for c in r.json()):
142- print(f"Skip duplicate comment on #{num}")
143- return
98+ r = s.get(url, params=params if 'params' in locals() else None)
99+ if r.status_code != 200 :
100+ print(f"Failed to list issues : {r.status_code} {r.text}", file=sys.stderr); sys.exit(1)
101+ for it in r.json() :
102+ if "pull_request" not in it :
103+ yield it
144104 url = r.links.get("next",{}).get("url")
145-
146- # 2) try to comment
105+ params = None
106+
107+ def parse_after_label(body:str, label:str) :
108+ " " "
109+ Finds the line immediately following a label header block:
110+ Label
111+ value
112+ Returns '' if not found.
113+ " " "
114+ pat = rf"{re.escape(label)}\s*\r?\n([^\n\r]+)"
115+ m = re.search(pat, body, re.IGNORECASE)
116+ return m.group(1).strip() if m else ""
117+
118+ def extract_fields(issue) :
119+ body = issue.get("body") or ""
120+ nominee_name = parse_after_label(body, "Nominee Name")
121+ nominee_email = parse_after_label(body, "Nominee Email")
122+ location = parse_after_label(body, "City, State/Province, Country")
123+ gh_handle = parse_after_label(body, "Nominee's GitHub or GitLab Handle")
124+
125+ # Normalize handle: if it's a URL like https://github.com/user -> take last path segment
126+ if gh_handle.startswith("http") :
127+ m = re.search(r"github\.com/([^/\s]+)", gh_handle, re.IGNORECASE)
128+ if m :
129+ gh_handle = m.group(1)
130+
131+ # First name: from nominee_name first token; fallback to profile name; then login
132+ first_name = ""
133+ if nominee_name :
134+ first_name = nominee_name.split()[0].strip()
135+ if not first_name :
136+ user = issue.get("user") or {}
137+ prof_name = (user.get("name") or "").strip()
138+ if prof_name :
139+ first_name = prof_name.split()[0]
140+ else :
141+ first_name = (user.get("login") or "there")
142+
143+ return {
144+ " issue_number " : issue["number"],
145+ " first_name " : first_name,
146+ " nominee_name " : nominee_name,
147+ " nominee_email " : nominee_email,
148+ " location " : location,
149+ " github_handle " : gh_handle
150+ }
151+
152+ def post_comment(issue_number, body) :
147153 if dry_run :
148- print(f"DRY-RUN: would comment on #{num }:\n{body} \n---")
154+ print(f"DRY-RUN : would post to #{issue_number }:\n{body[:300]}... \n---")
149155 return
150- r = s.post(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/comments", json={"body": body})
151- if r.status_code in (200,201):
152- return
153-
154- # 3) if locked and allowed, unlock -> comment -> re-lock
155- if r.status_code == 403 and "locked" in (r.text or "").lower():
156- if not UNLOCK_LOCKED:
157- print(f"Locked: #{num} (skipping; set UNLOCK_LOCKED=true to unlock temporarily).")
158- return
159- locked, reason = is_locked(num)
160- if not locked:
161- print(f"Comment 403 but issue #{num} not reported locked; skipping.")
162- return
163- if unlock_issue(num):
164- r2 = s.post(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/comments", json={"body": body})
165- if r2.status_code not in (200,201):
166- print(f"Warn: comment after UNLOCK failed for #{num}: {r2.status_code} {r2.text}")
167- relock_issue(num, reason) # re-lock regardless
168- return
169-
170- # 4) other failures
171- print(f"Warn: comment #{num} failed: {r.status_code} {r.text}")
172-
173- # messages + markers
174- ACCEPT_MARKER = "<!-- ambassador-decision:accepted-2025 -->"
175- REJECT_MARKER = "<!-- ambassador-decision:rejected-2025 -->"
176-
177- ACCEPT_MSG = (
178- "Hello {name},\n\n"
179- "Congratulations! 🎉 We're excited to inform you that you have been selected as a 2025 PyTorch Ambassador. "
180- "Your contributions, impact, and commitment to the PyTorch community stood out during the review process.\n\n"
181- "You should also have received an invitation to join our private program management repository, "
182- "where onboarding details and next steps will be shared. If you do not see the invite, please comment here and we will assist you.\n\n"
183- "Best regards,\n"
184- "The PyTorch PMO\n\n" + ACCEPT_MARKER
185- )
186- REJECT_MSG = (
187- "Hello {name},\n\n"
188- "Thank you for applying to the 2025 PyTorch Ambassador Program and for the valuable contributions you make in the PyTorch community. "
189- "After careful consideration, we regret to inform you that your application was not selected in this cycle.\n\n"
190- "We encourage you to apply again in the future — the next application cycle will open in early 2026. "
191- "Please keep an eye on our website for updates on the timeline: https://pytorch.org/programs/ambassadors/\n\n"
192- "We truly appreciate the time, effort, and passion you bring to the PyTorch community, and we hope to see your application again in the future.\n\n"
193- "If you have any questions in the meantime, please feel free to reach out to us at [email protected] .\n\n" 194- "Best regards,\n"
195- "The PyTorch PMO\n\n" + REJECT_MARKER
196- )
197-
198- # ---- Iterate decision.csv rows and post per-row messages with Candidate name ----
199- counts={"rows":0,"accept_commented":0,"reject_commented":0,"skipped":0}
200- try:
201- with open(dec_csv, newline="", encoding="utf-8") as f:
202- rdr = csv.DictReader(f)
203- hdr = {norm_key(h): h for h in (rdr.fieldnames or [])}
204- sid_key = hdr.get("submissionid") or hdr.get("issue") or hdr.get("issuenumber") or hdr.get("id")
205- decision_key = hdr.get("decision")
206- name_key = hdr.get("candidate")
207- if not sid_key or not decision_key or not name_key:
208- print("decision.csv must contain 'Submission ID', 'Decision', and 'Candidate' columns.", file=sys.stderr); sys.exit(1)
209-
210- for idx,row in enumerate(rdr, start=2):
211- counts["rows"] += 1
212- raw_id = (row.get(sid_key) or "").strip()
213- decision_raw = (row.get(decision_key) or "").strip().lower()
214- candidate = (row.get(name_key) or "").strip() or "there"
215-
216- if not raw_id:
217- counts["skipped"] += 1; continue
218- try:
219- num = int(float(raw_id))
220- except:
221- print(f"Row {idx}: invalid Submission ID '{raw_id}'; skipping.")
222- counts["skipped"] += 1; continue
223-
224- labels = get_issue_labels(num)
225-
226- if decision_raw in ("accept","accepted","approve","approved"):
227- if "Accepted" in labels:
228- add_comment_once(num, ACCEPT_MSG.format(name=candidate), ACCEPT_MARKER)
229- counts["accept_commented"] += 1
230- else:
231- print(f"Row {idx}: issue #{num} lacks 'Accepted' label; skipping.")
232- counts["skipped"] += 1
233-
234- elif decision_raw in ("reject","rejected","decline","declined"):
235- if "Rejected" in labels:
236- add_comment_once(num, REJECT_MSG.format(name=candidate), REJECT_MARKER)
237- counts["reject_commented"] += 1
238- else:
239- print(f"Row {idx}: issue #{num} lacks 'Rejected' label; skipping.")
240- counts["skipped"] += 1
241- else:
242- print(f"Row {idx}: unknown decision '{decision_raw}'; skipping.")
243- counts["skipped"] += 1
244- except FileNotFoundError:
245- print(f"decision.csv not found at: {dec_csv}", file=sys.stderr); sys.exit(1)
246-
247- print(f"Decisions: accept_commented={counts['accept_commented']}, reject_commented={counts['reject_commented']}, skipped={counts['skipped']} (rows={counts['rows']})")
248- if dry_run: print("DRY-RUN complete (no changes were made).")
156+ r = s.post(
157+ f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments",
158+ json={"body" : body}
159+ )
160+ if r.status_code not in (200,201) :
161+ print(f"Failed to comment on #{issue_number}: {r.status_code} {r.text}", file=sys.stderr)
162+
163+ # ---------- Process ----------
164+ rows = []
165+ count = 0
166+ for issue in get_accepted_open_issues() :
167+ data = extract_fields(issue)
168+ msg = MESSAGE_TEMPLATE.format(first_name=data["first_name"])
169+ post_comment(data["issue_number"], msg)
170+ count += 1
171+ rows.append([
172+ data["issue_number"],
173+ data["nominee_name"],
174+ data["nominee_email"],
175+ data["location"],
176+ data["github_handle"]
177+ ])
178+
179+ # Export CSV
180+ out = "accepted_export.csv"
181+ with open(out, "w", newline="", encoding="utf-8") as f :
182+ w = csv.writer(f)
183+ w.writerow(["Issue", "Nominee Name", "Nominee Email", "Location", "GitHub Handle"])
184+ w.writerows(rows)
185+
186+ print(f"Posted to {count} accepted issue(s).")
187+ print(f"Exported {len(rows)} row(s) -> {out}")
249188 PY
250189
190+ - name : Upload export artifact
191+ uses : actions/upload-artifact@v4
192+ with :
193+ name : accepted_export
194+ path : accepted_export.csv
0 commit comments