ci(dev-call): weekly Discourse post automation (#27087)

---------

Signed-off-by: FARHANG <farhang.nba@gmail.com>
Signed-off-by: Ramon Roche <mrpollo@gmail.com>
Co-authored-by: Ramon Roche <mrpollo@gmail.com>
This commit is contained in:
Farhang
2026-06-30 15:36:13 -04:00
committed by GitHub
parent f0a2fa9369
commit 8859bf44a2
3 changed files with 412 additions and 0 deletions

61
.github/dev_call_template.md vendored Normal file
View File

@@ -0,0 +1,61 @@
# PX4 Sync / Q&A - [date={{CALL_DATE}} time={{CALL_TIME}} timezone="{{CALL_TIMEZONE}}"]
**This week:** {{MERGED_COUNT}} PRs merged | {{REVIEW_COUNT}} awaiting review | {{BUG_COUNT}} new bugs
---
## Announcements
---
## Events
---
## Dev Call Items
{{DEV_CALL_ITEMS}}
---
## Release
**Updates:**
**Issues:**
**Decisions:**
---
## PRs Merged This Week
{{MERGED_PRS}}
## PRs Awaiting Review
{{REVIEW_PRS}}
---
## New Bug Reports
{{BUG_ISSUES}}
## Other New Issues
{{OTHER_ISSUES}}
---
## Q&A
---
## Action Items

50
.github/workflows/dev_call_post.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: PX4 Weekly Dev Call Post
on:
schedule:
# Every Monday at 10:00 UTC (2 days before Wednesday call)
# Format: minute hour day-of-month month day-of-week
# day-of-week: 0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat
- cron: '0 10 * * 1'
workflow_dispatch:
inputs:
dry_run:
description: 'Skip Discourse post and label cleanup — print rendered post body to logs only'
type: boolean
default: false
lookback_days:
description: 'Days back to search for merged PRs, new issues and bugs'
type: string
default: '7'
jobs:
post-dev-call:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Build and post Dev Call agenda
run: Tools/ci/dev_call_post.py
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_API_KEY }}
DISCOURSE_USER: ${{ vars.DISCOURSE_USER }}
DISCOURSE_URL: https://discuss.px4.io
CATEGORY_ID: '39'
DRY_RUN: ${{ inputs.dry_run }}
# Dev call policy knobs. Edit here, not in the script.
CALL_DAY: wednesday
CALL_TIME: '17:00:00'
CALL_TIMEZONE: Europe/Berlin
DEV_CALL_LABEL: Dev Call
LOOKBACK_DAYS: ${{ inputs.lookback_days || '7' }}

301
Tools/ci/dev_call_post.py Executable file
View File

@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""
Build and publish the weekly PX4 Dev Call agenda to Discourse.
Run from a scheduled GitHub Actions workflow (see
.github/workflows/dev_call_post.yml). All the logic that used to live as
inline shell in that workflow lives here instead, so it can be read,
reviewed, and run locally:
DRY_RUN=1 GH_TOKEN=... ./Tools/ci/dev_call_post.py
What it does:
1. Computes the next call date (Wednesday) and the one-week lookback.
2. Queries the GitHub Search API for the week's merged PRs, the open
review backlog, new bug reports, other new issues, and every open
issue/PR labeled "Dev Call".
3. Renders .github/dev_call_template.md by substituting {{PLACEHOLDER}}
tokens (literal string replace; no shell-escaping pitfalls).
4. Checks Discourse for an existing topic with the same title and skips
if found.
5. Posts the rendered agenda to the configured category.
6. Removes the "Dev Call" label from each item and leaves a backlink
comment. Uses the issues labels/comments API, which treats PRs and
issues uniformly, so PR items are handled correctly.
DRY_RUN=1 (or 'true') runs every read/build step and prints the rendered
body, but performs no Discourse post and no label mutation.
Policy knobs come from the environment (set in the workflow, not baked
into this script): CALL_DAY, CALL_TIME, CALL_TIMEZONE, DEV_CALL_LABEL,
LOOKBACK_DAYS, plus CATEGORY_ID/DISCOURSE_URL. Each has a sensible
default so the script also runs standalone.
Python stdlib only, to match the other Tools/ci scripts and avoid a
pip install step in CI.
"""
import datetime
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from _github_helpers import GitHubClient, fail
REPO = 'PX4/PX4-Autopilot'
REPO_URL = 'https://github.com/' + REPO
TEMPLATE = '.github/dev_call_template.md'
# Weekday names accepted by CALL_DAY, mapped to datetime.weekday() (Mon=0).
WEEKDAYS = {
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
'friday': 4, 'saturday': 5, 'sunday': 6,
}
def env(name, default=None, required=False):
value = os.environ.get(name, default)
if required and not value:
fail('missing required environment variable: {}'.format(name))
return value
def env_int(name, default):
value = env(name, str(default)) or str(default)
try:
return int(value)
except ValueError:
fail('{} must be an integer, got: {!r}'.format(name, value))
def is_truthy(value):
return str(value).strip().lower() in ('1', 'true', 'yes', 'on')
def compute_dates(today, call_weekday, lookback_days):
"""Return (call_date, display_date, since) as strings.
call_date is the next occurrence of `call_weekday` strictly after
`today` (so a run on the call day itself rolls to the following week).
`since` is `lookback_days` before `today`, the lower bound for the
"this week" GitHub queries.
"""
days_ahead = (call_weekday - today.weekday() + 7) % 7
if days_ahead == 0:
days_ahead = 7
call = today + datetime.timedelta(days=days_ahead)
since = today - datetime.timedelta(days=lookback_days)
return (
call.strftime('%Y-%m-%d'),
call.strftime('%b %d, %Y'),
since.strftime('%Y-%m-%d'),
)
def search_count(client, query):
"""Return total_count for a GitHub issues-search query."""
path = 'search/issues?q={}'.format(urllib.parse.quote(query))
body, _ = client.request('GET', path)
return int(body.get('total_count', 0))
def search_items(client, query, limit=50):
"""Return up to `limit` items (number/title/html_url) for a query."""
path = 'search/issues?q={}&per_page={}'.format(
urllib.parse.quote(query), limit)
body, _ = client.request('GET', path)
return body.get('items', [])
def link_or_placeholder(count, label, search_path, empty):
"""One-line markdown: a link to the GitHub search, or `empty` if zero."""
if count == 0:
return empty
return '[{} {} →]({}/{})'.format(count, label, REPO_URL, search_path)
def render(template_text, replacements):
out = template_text
for key, value in replacements.items():
out = out.replace('{{' + key + '}}', value)
return out
def build_dev_call_section(items):
if not items:
return '- None'
lines = []
for it in items:
lines.append('- [#{} {}]({})'.format(
it['number'], it['title'], it['html_url']))
return '\n'.join(lines)
class Discourse:
def __init__(self, base_url, api_key, api_user):
self._base = base_url.rstrip('/')
self._key = api_key
self._user = api_user
def _request(self, method, path, json_body=None):
url = '{}/{}'.format(self._base, path.lstrip('/'))
data = None
headers = {
'Api-Key': self._key,
'Api-Username': self._user,
}
if json_body is not None:
data = json.dumps(json_body).encode('utf-8')
headers['Content-Type'] = 'application/json'
req = urllib.request.Request(url, data=data, method=method,
headers=headers)
try:
with urllib.request.urlopen(req) as resp:
raw = resp.read()
return json.loads(raw.decode('utf-8')) if raw else None
except urllib.error.HTTPError as e:
body = e.read().decode('utf-8', errors='replace')
raise RuntimeError('Discourse {} {} failed: HTTP {} {}\n{}'.format(
method, url, e.code, e.reason, body))
def topic_exists(self, title):
path = 'search.json?q={}&in=title'.format(
urllib.parse.quote(title))
result = self._request('GET', path) or {}
for topic in result.get('topics', []):
if topic.get('title') == title:
return topic.get('id')
return None
def create_topic(self, title, raw, category_id):
result = self._request('POST', 'posts.json', {
'title': title,
'raw': raw,
'category': category_id,
}) or {}
topic_id = result.get('topic_id')
if not topic_id:
raise RuntimeError(
'Discourse did not return a topic_id: {}'.format(result))
return '{}/t/{}'.format(self._base, topic_id)
def clear_dev_call_labels(client, items, topic_url, label):
"""Remove the Dev Call label and leave a backlink comment on each item.
Uses the issues labels/comments endpoints, which accept both issue and
PR numbers, so PR items are handled the same as issues.
"""
comment = 'Added to the [Dev Call agenda]({}).'.format(topic_url)
label_path = urllib.parse.quote(label)
for it in items:
number = it['number']
client.request(
'DELETE',
'repos/{}/issues/{}/labels/{}'.format(REPO, number, label_path))
client.request(
'POST',
'repos/{}/issues/{}/comments'.format(REPO, number),
{'body': comment})
def main():
dry_run = is_truthy(env('DRY_RUN', 'false'))
token = env('GH_TOKEN', required=True)
client = GitHubClient(token, user_agent='px4-dev-call-post')
call_day_name = (env('CALL_DAY', 'wednesday') or '').strip().lower()
if call_day_name not in WEEKDAYS:
fail('CALL_DAY must be a weekday name, got: {!r}'.format(
env('CALL_DAY')))
lookback_days = env_int('LOOKBACK_DAYS', 7)
dev_call_label = env('DEV_CALL_LABEL', 'Dev Call')
timezone = env('CALL_TIMEZONE', 'Europe/Berlin')
call_time = env('CALL_TIME', '17:00:00')
today = datetime.date.today()
call_date, display_date, since = compute_dates(
today, WEEKDAYS[call_day_name], lookback_days)
merged_q = 'repo:{} is:pr is:merged merged:>={}'.format(REPO, since)
# Open review backlog: every open non-draft PR with no review, not just
# those opened this week. The old workflow filtered on created:>=, which
# hid the older backlog that makes up most of the queue.
review_q = 'repo:{} is:pr is:open draft:false review:none'.format(REPO)
bug_q = 'repo:{} is:issue created:>={} label:bug'.format(REPO, since)
other_q = 'repo:{} is:issue created:>={} -label:bug'.format(REPO, since)
dev_call_q = 'repo:{} is:open label:"{}"'.format(REPO, dev_call_label)
merged_count = search_count(client, merged_q)
review_count = search_count(client, review_q)
bug_count = search_count(client, bug_q)
other_count = search_count(client, other_q)
dev_call_items = search_items(client, dev_call_q)
replacements = {
'CALL_DATE': call_date,
'CALL_TIME': call_time,
'CALL_TIMEZONE': timezone,
'MERGED_COUNT': str(merged_count),
'REVIEW_COUNT': str(review_count),
'BUG_COUNT': str(bug_count),
'MERGED_PRS': link_or_placeholder(
merged_count, 'PRs merged this week',
'pulls?q=is:pr+is:merged+merged:>={}'.format(since),
'- None this week'),
'REVIEW_PRS': link_or_placeholder(
review_count, 'PRs awaiting review',
'pulls?q=is:pr+is:open+draft:false+review:none',
'- None pending'),
'BUG_ISSUES': link_or_placeholder(
bug_count, 'new bug reports',
'issues?q=is:issue+created:>={}+label:bug'.format(since),
'- No new bugs'),
'OTHER_ISSUES': link_or_placeholder(
other_count, 'new issues',
'issues?q=is:issue+created:>={}+-label:bug'.format(since),
'- None'),
'DEV_CALL_ITEMS': build_dev_call_section(dev_call_items),
}
with open(TEMPLATE, encoding='utf-8') as f:
body = render(f.read(), replacements)
title = 'PX4 Dev Call: {} (Team sync, and Community Q&A)'.format(
display_date)
if dry_run:
print('=== DRY RUN — title ===')
print(title)
print('=== DRY RUN — post body ===')
print(body)
print('=== {} Dev Call item(s) would be cleared ==='.format(
len(dev_call_items)))
return
discourse = Discourse(
env('DISCOURSE_URL', 'https://discuss.px4.io'),
env('DISCOURSE_API_KEY', required=True),
env('DISCOURSE_USER', required=True))
existing = discourse.topic_exists(title)
if existing:
print('Topic already exists ({}/t/{}); skipping post.'.format(
discourse._base, existing))
return
topic_url = discourse.create_topic(title, body, int(env('CATEGORY_ID', '39')))
print('Created: {}'.format(topic_url))
clear_dev_call_labels(client, dev_call_items, topic_url, dev_call_label)
print('Cleared Dev Call label from {} item(s).'.format(
len(dev_call_items)))
if __name__ == '__main__':
try:
main()
except RuntimeError as exc:
fail(str(exc))