Skip to content

Commit 1f426fd

Browse files
mfisher87RRosio
andcommitted
Add meeting notes automation workflow
Co-authored-by: Rosio <[email protected]>
1 parent f74da6f commit 1f426fd

File tree

5 files changed

+329
-1
lines changed

5 files changed

+329
-1
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
title: "Jupyter Community Committee Meeting"
3+
description: |
4+
A bi-weekly gathering of the Community Committee.
5+
date: "{{ date.strftime('%Y-%m-%d') }}"
6+
image: "../images/community-meeting.jpg"
7+
author:
8+
- name: "The Community Committee"
9+
categories:
10+
- "Meeting notes"
11+
tags: [meeting-notes]
12+
---
13+
14+
# Community Committee Meeting ({{ date.strftime("%Y-%m-%d") }})
15+
16+
Please add new agenda items under the `New agenda items` heading!
17+
18+
- [Previous meetings](https://jupyter.org/community-committee/meeting-notes/)
19+
- [Jupyter Community Committee](https://jupyter.org/governance/list-of-standing-committees-and-working-groups/#jupyter-community-building) handy links:
20+
- [GitHub repo](https://github.com/jupyter/community-committee)
21+
22+
23+
## Attendees
24+
25+
* Name
26+
* Name
27+
* Name
28+
* Name
29+
30+
31+
### Action items
32+
33+
- [ ] ...
34+
35+
36+
### Agenda & notes
37+
38+
- ...
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: "Setup / sync meeting notes"
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
kind:
7+
type: "choice"
8+
description: "What kind of meeting?"
9+
options:
10+
- "core"
11+
date:
12+
description: |
13+
Date of the meeting in `date --date` format, e.g.:
14+
"today", "tomorrow", "next wednesday", "2025-02-05", etc.
15+
See <https://www.gnu.org/software/coreutils/manual/html_node/Date-input-formats.html>
16+
required: false
17+
default: "today"
18+
type: "string"
19+
20+
issue_comment: # TODO: Switch to issue comment?
21+
types:
22+
- "created"
23+
- "edited"
24+
25+
26+
# IMPORTANT: Enable 'Allow GitHub Actions to create and approve pull requests'
27+
# in repo and organization Actions Settings.
28+
permissions:
29+
contents: "write"
30+
pull-requests: "write"
31+
32+
33+
jobs:
34+
create:
35+
name: "Create PR for meeting notes"
36+
if: "github.event_name == 'workflow_dispatch'"
37+
uses: "mfisher87/hackmd-meeting-notes-action/.github/workflows/create-meeting-notes-pr.yml@main"
38+
with:
39+
date: "${{ inputs.date }}"
40+
template_path: ".github/meeting-notes-templates/${{ inputs.kind }}.md"
41+
# TODO: Is this the HackMD path or the repo path??
42+
output_path: "meeting-notes/%Y%m%d-${{ inputs.kind }}/index.md"
43+
hackmd_team: "jupyter-community"
44+
branch_name: "%Y-%m-%d-${{ inputs.kind }}-meeting-notes"
45+
force_push: true
46+
pr_title: "Add ${{ inputs.kind }} meeting notes %Y-%m-%d"
47+
pr_body: |
48+
Meeting notes initialized at ${env.hackmd_doc_url}.
49+
50+
After the meeting, sync the notes from HackMD to this PR by commenting:
51+
52+
```
53+
/bot please sync notes
54+
```
55+
secrets:
56+
HACKMD_TOKEN: "${{ secrets.HACKMD_TOKEN }}"
57+
58+
sync-comment-trigger:
59+
name: "React to the triggering comment"
60+
if: "${{ github.event.issue.pull_request && contains(github.event.comment.body, '/bot please sync notes') }}"
61+
runs-on: "ubuntu-latest"
62+
steps:
63+
- name: "React to the triggering comment"
64+
run: |
65+
gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions --raw-field 'content=+1'
66+
env:
67+
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
68+
69+
sync:
70+
name: "Sync notes from HackMD to PR"
71+
needs:
72+
- "sync-comment-trigger"
73+
uses: "mfisher87/hackmd-meeting-notes-action/.github/workflows/sync-meeting-notes-pr.yml@main"
74+
with:
75+
pr_number: "${{ github.event.issue.number }}"
76+
secrets:
77+
HACKMD_TOKEN: "${{ secrets.HACKMD_TOKEN }}"

meeting-notes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Meeting notes
2+
3+
:::{listing}
4+
:::

myst.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@ project:
66
# keywords: []
77
# authors: []
88
github: "https://github.com/jupyter/community"
9-
# To autogenerate a Table of Contents, run "myst init --write-toc"
9+
plugins:
10+
- type: "executable"
11+
path: "plugin/listing.py"
12+
toc:
13+
- file: "index.md"
14+
- file: "charter.md"
15+
- file: "meeting-notes.md"
16+
children:
17+
- pattern: "meeting-notes/**/*.md"
18+
hidden: true
1019
site:
1120
template: "book-theme"
1221
# options:

plugin/listing.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
#!/usr/bin/env python3
2+
# With many thanks, based on work by Chris Holdgraf
3+
# https://github.com/choldgraf/choldgraf.github.io/blob/35f2a24818ec73304a9769153796a952c0ec2561/src/blogpost.py
4+
5+
import argparse
6+
import json
7+
import sys
8+
from pathlib import Path
9+
10+
import pandas as pd
11+
from feedgen.feed import FeedGenerator
12+
from yaml import safe_load
13+
14+
ROOT = Path(__file__).parent.parent
15+
LISTING_DIR = ROOT / "meeting-notes"
16+
SUMMARY_WORDS = 50
17+
18+
DEFAULTS = {"number": 0}
19+
PLUGIN_SPEC = {
20+
"name": "A document listing",
21+
"directives": [
22+
{
23+
"name": "listing",
24+
"doc": "A listing of documents as cards, sorted by date.",
25+
"arg": {},
26+
"options": {
27+
"number": {
28+
"type": "int",
29+
"doc": "The number of posts to include (default: 0, or all posts)",
30+
},
31+
},
32+
},
33+
],
34+
}
35+
36+
37+
def aggregate_posts() -> list[dict]:
38+
"""Aggregate all posts from the markdown and ipynb files."""
39+
posts = []
40+
for ifile in LISTING_DIR.rglob("**/*.md"):
41+
if "drafts" in str(ifile):
42+
continue
43+
44+
text = ifile.read_text()
45+
try:
46+
_, meta, content = text.split("---", 2)
47+
except Exception:
48+
print(f"Skipping file with malformed frontmatter: {ifile}", file=sys.stderr)
49+
continue
50+
51+
# Load in YAML metadata
52+
meta = safe_load(meta)
53+
meta["path"] = ifile.relative_to(ROOT).with_suffix("")
54+
if "title" not in meta:
55+
lines = text.splitlines()
56+
for ii in lines:
57+
if ii.strip().startswith("#"):
58+
meta["title"] = ii.replace("#", "").strip()
59+
break
60+
61+
# Summarize content
62+
skip_lines = ["#", "--", "%", "++"]
63+
content = "\n".join(
64+
ii
65+
for ii in content.splitlines()
66+
if not any(ii.startswith(char) for char in skip_lines)
67+
)
68+
words = " ".join(content.split(" ")[:SUMMARY_WORDS])
69+
if "author" not in meta or not meta["author"]:
70+
meta["author"] = "TODO: Get from myst.yml"
71+
meta["content"] = meta.get("description", words)
72+
posts.append(meta)
73+
74+
if not posts:
75+
return pd.DataFrame()
76+
77+
posts = pd.DataFrame(posts)
78+
# TODO: Why do we care about TZ?
79+
posts["date"] = pd.to_datetime(posts["date"]).dt.tz_localize("US/Pacific")
80+
posts = posts.dropna(subset=["date"])
81+
return posts.sort_values("date", ascending=False)
82+
83+
84+
def cards_from_posts(posts, /) -> list[dict]:
85+
def ast_text(value, **kwargs):
86+
return {"type": "text", "value": value, **kwargs}
87+
88+
def ast_strong(children, **kwargs):
89+
return {"type": "strong", "children": children, **kwargs}
90+
91+
cards = []
92+
for _, irow in posts.iterrows():
93+
cards.append(
94+
{
95+
"type": "card",
96+
"url": f"{irow['path'].with_suffix('')}",
97+
"children": [
98+
{"type": "cardTitle", "children": [ast_text(irow["title"])]},
99+
{"type": "paragraph", "children": [ast_text(irow["content"])]},
100+
{
101+
"type": "footer",
102+
"children": [
103+
ast_strong([ast_text("Date: ")]),
104+
ast_text(f"{irow['date']:%B %d, %Y} | "),
105+
ast_strong([ast_text("Author: ")]),
106+
ast_text(f"{irow['author'][0]['name']}"),
107+
],
108+
},
109+
],
110+
},
111+
)
112+
return cards
113+
114+
115+
def write_feeds(*, posts) -> None:
116+
"""Generate RSS and Atom feeds and write them as XML."""
117+
# TODO: Get URL from myst.yaml
118+
base_url = "https://example.com"
119+
120+
fg = FeedGenerator()
121+
122+
fg.id(base_url)
123+
fg.title("TODO: Get title from myst.yaml")
124+
fg.author(
125+
{
126+
"name": "TODO: Get author from individual posts",
127+
"email": "TODO: Get email from individual posts",
128+
},
129+
)
130+
fg.link(href=base_url, rel="alternate")
131+
fg.logo("TODO: Get logo from myst.yaml")
132+
fg.subtitle("TODO: Get description from myst.yaml")
133+
# TODO: This link is going to be wrong because it doesn't take into account
134+
# LISTING_DIR
135+
fg.link(href=f"{base_url}/rss.xml", rel="self")
136+
fg.language("en")
137+
138+
# Add all posts
139+
for _, irow in posts.iterrows():
140+
fe = fg.add_entry()
141+
fe.id(f"{base_url}/{irow['path']}")
142+
fe.published(irow["date"])
143+
fe.title(irow["title"])
144+
fe.link(href=f"{base_url}/{irow['path']}")
145+
fe.content(content=irow["content"])
146+
147+
# Write an RSS feed with latest posts
148+
# TODO: Only write this to the build output, don't commit it
149+
fg.atom_file(LISTING_DIR / "atom.xml", pretty=True)
150+
fg.rss_file(LISTING_DIR / "rss.xml", pretty=True)
151+
152+
153+
def print_result(content, /):
154+
"""Write result as JSON to stdout.
155+
156+
:param content: content to write to stdout
157+
"""
158+
159+
# Format result and write to stdout
160+
json.dump(content, sys.stdout, indent=2)
161+
# Successfully exit
162+
raise SystemExit(0)
163+
164+
165+
def run_directive(name, /) -> None:
166+
"""Execute a directive with the given name and data
167+
168+
:param name: name of the directive to run
169+
"""
170+
assert name == "listing"
171+
172+
data = json.load(sys.stdin)
173+
174+
opts = data["node"].get("options", {})
175+
176+
posts = aggregate_posts()
177+
write_feeds(posts=posts)
178+
179+
cards = cards_from_posts(posts)
180+
number = int(opts.get("number", DEFAULTS["number"]))
181+
output = cards if number == 0 else cards[:number]
182+
print_result(output)
183+
184+
185+
if __name__ == "__main__":
186+
parser = argparse.ArgumentParser()
187+
group = parser.add_mutually_exclusive_group()
188+
group.add_argument("--role")
189+
group.add_argument("--directive")
190+
group.add_argument("--transform")
191+
args = parser.parse_args()
192+
193+
if args.directive:
194+
run_directive(args.directive)
195+
elif args.transform:
196+
raise NotImplementedError
197+
elif args.role:
198+
raise NotImplementedError
199+
else:
200+
print_result(PLUGIN_SPEC)

0 commit comments

Comments
 (0)