on fa01460 help sign in
Multigram/scripts/sync-report.py
272 lines 11 kB history raw
1#!/usr/bin/env python3
2"""Report a "Sync with Forkgram" run on the GitHub issue labelled forkgram-sync.
4Runs in the report job of .github/workflows/sync-forkgram.yml, after the sync, compile and
5promote jobs. It reads their results and the sync outputs from the environment and:
7 - conflict, compile failure, promote failure, a sync error, or a multigram-next changed by
8 hand that the sync would have replaced: opens an issue, or comments on the open one, with
9 what happened and the local commands to fix it (the same event is reported once, however
10 often the schedule runs);
11 - multigram still behind with no new release: opens an issue only if none is open;
12 - promoted, or multigram in step with Forkgram: closes the open issue with a note (naming
13 any MultiGram commits the new multigram leaves out).
15Environment: SYNC_RESULT, COMPILE_RESULT, PROMOTE_RESULT (job results); SYNC_* (the sync
16job's outputs, e.g. SYNC_STATUS, SYNC_FORKGRAM_VERSION); PROMOTE_TAG; RUN_URL; GH_TOKEN
17and GH_REPO for the gh CLI. REPORT_GH names another gh binary (the self-test uses a fake).
18Standard library only.
19"""
20import hashlib
21import json
22import os
23import re
24import subprocess
25import sys
26import tempfile
28LABEL = 'forkgram-sync'
29GH = os.environ.get('REPORT_GH', 'gh')
30SHA = re.compile(r'^[0-9a-f]{40}$')
31VERSION = re.compile(r'^[0-9A-Za-z][0-9A-Za-z._+-]{0,39}$')
34def env(name):
35 return os.environ.get(name, '').strip()
38def sha(name):
39 value = env(name)
40 return value if SHA.match(value) else ''
43def short(value):
44 return value[:10] if value else '?'
47def version():
48 value = env('SYNC_FORKGRAM_VERSION')
49 return value if VERSION.match(value) else 'unknown'
52def block(text):
53 """Untrusted text (commit subjects, file names, errors) as a Markdown code block."""
54 text = text.replace('`', "'").strip() or '(none)'
55 return '```text\n' + text + '\n```'
58def gh(*args):
59 run = subprocess.run([GH, *args], capture_output=True, text=True)
60 if run.returncode != 0:
61 sys.stderr.write(run.stderr)
62 raise SystemExit(f'gh {args[0]} {args[1] if len(args) > 1 else ""} failed')
63 return run.stdout
66def gh_body(*args, body):
67 with tempfile.NamedTemporaryFile('w', suffix='.md', delete=False) as f:
68 f.write(body)
69 path = f.name
70 try:
71 return gh(*args, '--body-file', path)
72 finally:
73 os.unlink(path)
76def open_issues():
77 issues = json.loads(gh('issue', 'list', '--label', LABEL, '--state', 'open',
78 '--json', 'number,title', '--limit', '20') or '[]')
79 return sorted(i['number'] for i in issues)
82def open_issue():
83 numbers = open_issues()
84 return numbers[0] if numbers else None
87def already_reported(number, key):
88 data = json.loads(gh('issue', 'view', str(number), '--json', 'body,comments'))
89 texts = [data.get('body') or ''] + [c.get('body') or '' for c in data.get('comments', [])]
90 return any(key in t for t in texts)
93def marker(key):
94 return f'<!-- forkgram-sync-key: {key} -->'
97def title():
98 return f'Forkgram sync needs a hand (Forkgram {version()})'
101def run_line():
102 url = env('RUN_URL')
103 return f'Run: {url}' if url.startswith('https://') else ''
106def use_next_step():
107 return ('Then open **Actions > Sync with Forkgram > Run workflow**, tick **use_next** and run '
108 'it. It compiles `multigram-next` and, if that passes, moves `multigram` to it (the '
109 'old tip is kept as the tag `multigram-before-<version>`) and closes this issue.')
112def body_conflict():
113 snap, prev = sha('SYNC_SNAPSHOT') or '<new snapshot>', sha('SYNC_PREVIOUS_SNAPSHOT') or '<old snapshot>'
114 commit = sha('SYNC_CONFLICT_COMMIT')
115 return f"""**Forkgram {version()}** is out and is now on `forkgram` (snapshot `{short(snap)}`), but the MultiGram stack does not apply to it. `multigram` is unchanged.
117MultiGram commit `{short(commit)}` ({env('SYNC_CONFLICT_POSITION') or '?'}) stops the rebase:
118{block(env('SYNC_CONFLICT_SUBJECT'))}
119Files:
120{block(env('SYNC_CONFLICT_DETAILS') or env('SYNC_CONFLICT_FILES'))}
121{run_line()}
123### Fix it locally
125```sh
126git fetch origin
127git switch -C multigram-next origin/multigram
128git config rerere.enabled true # reuses your fixes if you redo this rebase in this clone
129git rebase --onto {snap} {prev}
130# fix the files git names, then:
131git add <files>
132git rebase --continue # repeat until the rebase is done
133git push --force origin multigram-next
134```
136To leave a MultiGram commit out instead (Forkgram now does the same thing, say), run `git rebase --skip` at that commit.
138{use_next_step()}
139"""
142def body_compile():
143 snap, cand = sha('SYNC_SNAPSHOT') or '<snapshot>', sha('SYNC_CANDIDATE')
144 return f"""The MultiGram stack applied cleanly to **Forkgram {version()}**, but the compile check failed. `multigram` is unchanged; the candidate is on `multigram-next` (`{short(cand)}`).
146{run_line()}
148If the log shows a runner or network hiccup, use **Re-run failed jobs** on the run page. Otherwise:
150### Fix it locally
152```sh
153git fetch origin
154git switch -C multigram-next origin/multigram-next
155# fix the build, then fold the fix into the MultiGram commit it belongs to:
156git commit -a --fixup=<that commit>
157GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash {snap}
158git push --force origin multigram-next
159```
161{use_next_step()}
162"""
165def body_promote():
166 cand = sha('SYNC_CANDIDATE')
167 return f"""The candidate for **Forkgram {version()}** compiled, but moving `multigram` to it failed, usually because someone pushed to `multigram` or `multigram-next` while the sync ran. `multigram` is unchanged; the candidate is still on `multigram-next` (`{short(cand)}`).
169{run_line()}
171Check that `multigram-next` holds everything `multigram` has now (rebase any new commits onto it and push it), then run **Actions > Sync with Forkgram** with **use_next**.
172"""
175def body_error():
176 return f"""The sync stopped with an error. Nothing was pushed after the error; `multigram` is unchanged.
178{block(env('SYNC_ERROR') or env('SYNC_MESSAGE') or 'The sync job failed before it could say why; see the run log.')}
179{run_line()}
181Fix the cause (the message says what it is), then run **Actions > Sync with Forkgram** again.
182"""
185def body_next_edited():
186 nxt, tip, fc = sha('SYNC_EXISTING_NEXT'), sha('SYNC_FORKGRAM_TIP'), sha('SYNC_FORKGRAM_COMMIT')
187 if tip and tip != fc:
188 what = f"Forkgram's `dev` has moved on (`{short(tip)}`), but the sync did not take it"
189 else:
190 what = 'The sync was asked to rebuild `multigram-next` (force), but did not'
191 return f"""{what}: `multigram-next` (`{short(nxt)}`) has commits the sync did not make, most likely a fix by hand, and the sync never replaces those. Nothing was pushed; `forkgram` and `multigram` are unchanged.
193{run_line()}
195- If `multigram-next` is ready, run **Actions > Sync with Forkgram > Run workflow** with **use_next**: it compiles `multigram-next` and, if that passes, moves `multigram` to it. The next scheduled sync then takes the new Forkgram commit.
196- If you do not need it any more, delete it (`git push origin --delete multigram-next`); the next sync rebuilds it from `multigram`.
197"""
200def body_behind():
201 return f"""`forkgram` has **Forkgram {version()}** (snapshot `{short(sha('SYNC_SNAPSHOT'))}`), but `multigram` is still based on the older snapshot `{short(sha('SYNC_MULTIGRAM_BASE'))}`: an earlier sync could not move it and no issue is open about it.
203{run_line()}
205Run **Actions > Sync with Forkgram** with **force** to try again; if the stack still does not apply, this issue gets the details and the commands to fix it.
206"""
209def report(key, body):
210 number = open_issue()
211 text = marker(key) + '\n' + body
212 if number is None:
213 gh('label', 'create', LABEL, '--color', 'D93F0B', '--force',
214 '--description', 'The automatic sync with Forkgram needs a hand')
215 print(gh_body('issue', 'create', '--title', title(), '--label', LABEL, body=text).strip())
216 return
217 if key and already_reported(number, marker(key)):
218 print(f'issue #{number} already reports this; nothing to add')
219 return
220 gh('issue', 'edit', str(number), '--title', title())
221 gh_body('issue', 'comment', str(number), body=text)
222 print(f'commented on issue #{number}')
225def close(note):
226 for number in open_issues():
227 gh('issue', 'close', str(number), '--reason', 'completed', '--comment', note)
228 print(f'closed issue #{number}')
231def main():
232 results = [env('SYNC_RESULT'), env('COMPILE_RESULT'), env('PROMOTE_RESULT')]
233 status = env('SYNC_STATUS')
234 print(f'sync={results[0]} compile={results[1]} promote={results[2]} status={status or "-"}')
235 if 'cancelled' in results:
236 print('a job was cancelled; nothing to report')
237 return
238 fc = sha('SYNC_FORKGRAM_COMMIT')
239 cand = sha('SYNC_CANDIDATE')
240 if status == 'conflict':
241 report(f'conflict {fc} {sha("SYNC_CONFLICT_COMMIT")}', body_conflict())
242 elif results[0] != 'success':
243 err = env('SYNC_ERROR') or 'unknown'
244 report('error ' + hashlib.sha1(err.encode()).hexdigest()[:12], body_error())
245 elif status == 'next-edited':
246 report(f'next-edited {sha("SYNC_EXISTING_NEXT")} {sha("SYNC_FORKGRAM_TIP")}', body_next_edited())
247 elif status == 'candidate' and results[1] == 'failure':
248 report(f'compile {cand}', body_compile())
249 elif status == 'candidate' and results[1] == 'success' and results[2] == 'failure':
250 report(f'promote {cand}', body_promote())
251 elif status == 'candidate' and results[2] == 'success':
252 tag = env('PROMOTE_TAG')
253 moved = f'the old tip is tagged `{tag}`' if re.match(r'^[0-9A-Za-z._-]+$', tag) else 'it already was the candidate'
254 note = f'Done: `multigram` now carries Forkgram {version()} (`{short(cand)}`); {moved}. {run_line()}'.strip()
255 if env('SYNC_DROPPED'):
256 note += ('\n\nThese MultiGram commits are not on the new `multigram` (Forkgram already has '
257 'them, or `multigram-next` left them out with allow_drop):\n' + block(env('SYNC_DROPPED')))
258 close(note)
259 elif status == 'behind':
260 if open_issue() is None:
261 report('', body_behind())
262 else:
263 print('multigram is behind and an issue is already open; nothing to add')
264 elif status == 'up-to-date':
265 if open_issue() is not None:
266 close(f'`multigram` is in step with Forkgram {version()} again. {run_line()}'.strip())
267 else:
268 print('nothing to report')
271if __name__ == '__main__':
272 main()