mirror of
https://github.com/PX4/PX4-Autopilot.git
synced 2026-07-24 15:27:41 +08:00
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:
61
.github/dev_call_template.md
vendored
Normal file
61
.github/dev_call_template.md
vendored
Normal 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
50
.github/workflows/dev_call_post.yml
vendored
Normal 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
301
Tools/ci/dev_call_post.py
Executable 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))
|
||||
Reference in New Issue
Block a user