|
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 "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).") |
249 | | -PY |
250 | 1 |
|
0 commit comments