Skip to content

Commit c17ac66

Browse files
Create ambassador-comments.yml
1 parent 0eeb7a1 commit c17ac66

File tree

1 file changed

+227
-0
lines changed

1 file changed

+227
-0
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
name: "Ambassador comments (decisions only)"
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
private_repo:
7+
description: "Private repo containing the CSVs (owner/name)"
8+
required: true
9+
default: pytorch-fdn/ambassador-program-management
10+
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
26+
dry_run:
27+
description: "Preview without posting"
28+
required: true
29+
default: false
30+
type: boolean
31+
32+
permissions:
33+
contents: read
34+
issues: write
35+
36+
concurrency:
37+
group: ambassador-comments
38+
cancel-in-progress: true
39+
40+
jobs:
41+
comment:
42+
runs-on: ubuntu-latest
43+
steps:
44+
- name: Checkout PUBLIC repo
45+
uses: actions/checkout@v4
46+
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+
55+
- name: Set up Python
56+
uses: actions/setup-python@v5
57+
with:
58+
python-version: "3.11"
59+
60+
- name: Install dependencies
61+
run: pip install requests
62+
63+
- name: Post comments from decision.csv
64+
env:
65+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
66+
REPO_FULL: ${{ github.repository }}
67+
DEC_CSV_ABS: ${{ github.workspace }}/private-src/${{ inputs.decision_csv_path }}
68+
UNLOCK_LOCKED: ${{ inputs.unlock_locked_issues }}
69+
DRY_RUN: ${{ inputs.dry_run }}
70+
run: |
71+
python - <<'PY'
72+
import os, csv, re, sys, urllib.parse
73+
import requests
74+
from requests.adapters import HTTPAdapter, Retry
75+
76+
token = os.getenv("GITHUB_TOKEN")
77+
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")
81+
82+
if not token or not repo_full:
83+
print("Missing GITHUB_TOKEN or REPO_FULL", file=sys.stderr); sys.exit(1)
84+
85+
owner, repo = repo_full.split("/", 1)
86+
87+
# session with retries
88+
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"
136+
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
144+
url = r.links.get("next",{}).get("url")
145+
146+
# 2) try to comment
147+
if dry_run:
148+
print(f"DRY-RUN: would comment on #{num}:\n{body}\n---")
149+
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

0 commit comments

Comments
 (0)