Skip to content

Commit 3597cc6

Browse files
Create ambassador-maintenance.yml
1 parent c936de8 commit 3597cc6

File tree

1 file changed

+274
-0
lines changed

1 file changed

+274
-0
lines changed
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
name: "Ambassador maintenance (duplicates + decisions + cleanup)"
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+
duplicates_csv_path:
17+
description: "Path to duplicates CSV"
18+
required: true
19+
default: .github/ISSUE_TEMPLATE/duplicates.csv
20+
type: string
21+
decision_csv_path:
22+
description: "Path to decision CSV (has Submission ID, Decision, Candidate)"
23+
required: true
24+
default: .github/ISSUE_TEMPLATE/decision.csv
25+
type: string
26+
dry_run:
27+
description: "Preview without writing comments/labels"
28+
required: true
29+
default: false
30+
type: boolean
31+
32+
permissions:
33+
contents: read
34+
issues: write
35+
36+
concurrency:
37+
group: ambassador-maintenance
38+
cancel-in-progress: true
39+
40+
jobs:
41+
apply:
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: Process duplicates, post decisions by row, clean pending-review
64+
env:
65+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
66+
REPO_FULL: ${{ github.repository }}
67+
DUP_CSV_ABS: ${{ github.workspace }}/private-src/${{ inputs.duplicates_csv_path }}
68+
DEC_CSV_ABS: ${{ github.workspace }}/private-src/${{ inputs.decision_csv_path }}
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+
dup_csv = os.getenv("DUP_CSV_ABS")
79+
dec_csv = os.getenv("DEC_CSV_ABS")
80+
dry_run = (os.getenv("DRY_RUN","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+
# resilient session
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 ensure_label(name, color):
100+
enc = urllib.parse.quote(name, safe="")
101+
r = s.get(f"https://api.github.com/repos/{owner}/{repo}/labels/{enc}")
102+
if r.status_code==200: return
103+
if r.status_code==404:
104+
if dry_run: print(f"DRY-RUN: would create label '{name}' #{color}"); return
105+
r = s.post(f"https://api.github.com/repos/{owner}/{repo}/labels",
106+
json={"name":name,"color":color})
107+
if r.status_code not in (200,201):
108+
print(f"Warn: create label {name} failed: {r.status_code} {r.text}")
109+
else:
110+
print(f"Warn: get label {name} failed: {r.status_code} {r.text}")
111+
112+
def get_issue(num): return s.get(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}")
113+
114+
def get_issue_labels(num):
115+
r = s.get(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/labels?per_page=100")
116+
if r.status_code==200: return {x["name"] for x in r.json()}
117+
print(f"Warn: get labels #{num} failed: {r.status_code} {r.text}"); return set()
118+
119+
def add_labels(num, labels):
120+
if not labels: return
121+
if dry_run: print(f"DRY-RUN: would add labels to #{num}: {sorted(labels)}"); return
122+
r = s.post(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/labels",
123+
json={"labels": list(labels)})
124+
if r.status_code not in (200,201):
125+
print(f"Warn: add labels #{num} failed: {r.status_code} {r.text}")
126+
127+
def remove_label(num, label):
128+
if dry_run: print(f"DRY-RUN: would remove label '{label}' from #{num}"); return
129+
enc = urllib.parse.quote(label, safe="")
130+
r = s.delete(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/labels/{enc}")
131+
if r.status_code not in (200,204):
132+
print(f"Warn: remove label #{num} failed: {r.status_code} {r.text}")
133+
134+
def add_comment_once(num, body, marker):
135+
# avoid duplicate comments using hidden marker
136+
url = f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/comments?per_page=100"
137+
while url:
138+
r = s.get(url)
139+
if r.status_code!=200: print(f"Warn: list comments #{num} failed: {r.status_code} {r.text}"); break
140+
if any(marker in (c.get("body") or "") for c in r.json()):
141+
print(f"Skip duplicate comment on #{num}"); return
142+
url = r.links.get("next",{}).get("url")
143+
if dry_run: print(f"DRY-RUN: would comment on #{num}:\n{body}\n---"); return
144+
r = s.post(f"https://api.github.com/repos/{owner}/{repo}/issues/{num}/comments", json={"body": body})
145+
if r.status_code not in (200,201): print(f"Warn: comment #{num} failed: {r.status_code} {r.text}")
146+
147+
def list_issues_with_label(label):
148+
url = f"https://api.github.com/repos/{owner}/{repo}/issues?state=all&per_page=100&labels={urllib.parse.quote(label, safe='')}"
149+
while url:
150+
r = s.get(url)
151+
if r.status_code!=200: print(f"Warn: list issues for '{label}' failed: {r.status_code} {r.text}"); return
152+
for it in r.json():
153+
if "pull_request" in it: continue
154+
yield it
155+
url = r.links.get("next",{}).get("url")
156+
157+
# labels & messages
158+
ACCEPT_LABEL, REJECT_LABEL, DUP_LABEL, PENDING_LABEL = "Accepted","Rejected","Duplicate","pending-review"
159+
ensure_label(ACCEPT_LABEL, "2ea44f")
160+
ensure_label(REJECT_LABEL, "d73a4a")
161+
ensure_label(DUP_LABEL, "cfd3d7")
162+
163+
ACCEPT_MARKER = "<!-- ambassador-decision:accepted-2025 -->"
164+
REJECT_MARKER = "<!-- ambassador-decision:rejected-2025 -->"
165+
166+
ACCEPT_MSG = (
167+
"Hello {name},\n\n"
168+
"Congratulations! 🎉 We're excited to inform you that you have been selected as a 2025 PyTorch Ambassador. "
169+
"Your contributions, impact, and commitment to the PyTorch community stood out during the review process.\n\n"
170+
"You should also have received an invitation to join our private program management repository, "
171+
"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"
172+
"Best regards,\n"
173+
"The PyTorch PMO\n\n" + ACCEPT_MARKER
174+
)
175+
REJECT_MSG = (
176+
"Hello {name},\n\n"
177+
"Thank you for applying to the 2025 PyTorch Ambassador Program and for the valuable contributions you make in the PyTorch community. "
178+
"After careful consideration, we regret to inform you that your application was not selected in this cycle.\n\n"
179+
"We encourage you to apply again in the future — the next application cycle will open in early 2026. "
180+
"Please keep an eye on our website for updates on the timeline: https://pytorch.org/programs/ambassadors/\n\n"
181+
"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"
182+
"If you have any questions in the meantime, please feel free to reach out to us at [email protected].\n\n"
183+
"Best regards,\n"
184+
"The PyTorch PMO\n\n" + REJECT_MARKER
185+
)
186+
187+
# --- Part 1: Label duplicates from duplicates.csv (no closing) ---
188+
dup_counts={"total":0,"labeled":0,"missing":0,"skipped":0}
189+
try:
190+
with open(dup_csv, newline="", encoding="utf-8") as f:
191+
rdr = csv.DictReader(f)
192+
hdr = {norm_key(h): h for h in (rdr.fieldnames or [])}
193+
id_key = hdr.get("issue") or hdr.get("submissionid") or hdr.get("issuenumber") or hdr.get("id")
194+
if not id_key:
195+
print("duplicates.csv must have an issue id column (e.g., 'Issue' or 'Submission ID').", file=sys.stderr); sys.exit(1)
196+
for idx,row in enumerate(rdr, start=2):
197+
dup_counts["total"] += 1
198+
raw = (row.get(id_key) or "").strip()
199+
if not raw: dup_counts["skipped"] += 1; continue
200+
try: num = int(float(raw))
201+
except: print(f"Row {idx}: invalid ID '{raw}'; skipping."); dup_counts["skipped"] += 1; continue
202+
r = get_issue(num)
203+
if r.status_code==404: print(f"Issue #{num} not found; skipping."); dup_counts["missing"] += 1; continue
204+
if r.status_code!=200: print(f"Warn: GET issue #{num} failed: {r.status_code} {r.text}"); dup_counts["skipped"] += 1; continue
205+
existing = get_issue_labels(num)
206+
to_add = {"Duplicate"} - existing
207+
add_labels(num, to_add)
208+
if to_add: dup_counts["labeled"] += 1
209+
except FileNotFoundError:
210+
print(f"duplicates.csv not found at: {dup_csv}", file=sys.stderr); sys.exit(1)
211+
212+
# --- Part 2: Iterate decision.csv rows and post per-row messages with Candidate name ---
213+
# Expects columns: Submission ID, Decision, Candidate
214+
dec_counts={"rows":0,"accept_commented":0,"reject_commented":0,"skipped":0}
215+
try:
216+
with open(dec_csv, newline="", encoding="utf-8") as f:
217+
rdr = csv.DictReader(f)
218+
hdr = {norm_key(h): h for h in (rdr.fieldnames or [])}
219+
sid_key = hdr.get("submissionid") or hdr.get("issue") or hdr.get("issuenumber") or hdr.get("id")
220+
decision_key = hdr.get("decision")
221+
name_key = hdr.get("candidate")
222+
if not sid_key or not decision_key or not name_key:
223+
print("decision.csv must contain 'Submission ID', 'Decision', and 'Candidate' columns.", file=sys.stderr); sys.exit(1)
224+
225+
for idx,row in enumerate(rdr, start=2):
226+
dec_counts["rows"] += 1
227+
raw_id = (row.get(sid_key) or "").strip()
228+
decision_raw = (row.get(decision_key) or "").strip().lower()
229+
candidate = (row.get(name_key) or "").strip() or "there"
230+
231+
if not raw_id:
232+
dec_counts["skipped"] += 1; continue
233+
try:
234+
num = int(float(raw_id))
235+
except:
236+
print(f"Row {idx}: invalid Submission ID '{raw_id}'; skipping.")
237+
dec_counts["skipped"] += 1; continue
238+
239+
# Only comment if the label matches (safety) and avoid duplicates via marker
240+
if decision_raw in ("accept","accepted","approve","approved"):
241+
# optional: ensure Accepted label exists on the issue before commenting
242+
labels = get_issue_labels(num)
243+
if "Accepted" in labels:
244+
add_comment_once(num, ACCEPT_MSG.format(name=candidate), ACCEPT_MARKER)
245+
dec_counts["accept_commented"] += 1
246+
else:
247+
print(f"Row {idx}: issue #{num} lacks 'Accepted' label; skipping comment.")
248+
dec_counts["skipped"] += 1
249+
250+
elif decision_raw in ("reject","rejected","decline","declined"):
251+
labels = get_issue_labels(num)
252+
if "Rejected" in labels:
253+
add_comment_once(num, REJECT_MSG.format(name=candidate), REJECT_MARKER)
254+
dec_counts["reject_commented"] += 1
255+
else:
256+
print(f"Row {idx}: issue #{num} lacks 'Rejected' label; skipping comment.")
257+
dec_counts["skipped"] += 1
258+
else:
259+
print(f"Row {idx}: unknown decision '{decision_raw}'; skipping.")
260+
dec_counts["skipped"] += 1
261+
except FileNotFoundError:
262+
print(f"decision.csv not found at: {dec_csv}", file=sys.stderr); sys.exit(1)
263+
264+
# --- Part 3: Remove 'pending-review' everywhere ---
265+
removed = 0
266+
for issue in list_issues_with_label(PENDING_LABEL) or []:
267+
remove_label(issue["number"], PENDING_LABEL)
268+
removed += 1
269+
270+
print(f"Duplicates: labeled={dup_counts['labeled']} / total_rows={dup_counts['total']}, missing={dup_counts['missing']}, skipped={dup_counts['skipped']}")
271+
print(f"Decisions: accept_commented={dec_counts['accept_commented']}, reject_commented={dec_counts['reject_commented']}, skipped={dec_counts['skipped']} (rows={dec_counts['rows']})")
272+
print(f"pending-review removed from {removed} issues.")
273+
if dry_run: print("DRY-RUN complete (no changes were made).")
274+
PY

0 commit comments

Comments
 (0)