Skip to content

Commit

Permalink
feat: add script to automate README updates for DOC-16 (#122)
Browse files Browse the repository at this point in the history
This PR introduces a script to automate the process of updating the
README.md file. The script ensures that tables in the README are
refreshed with new metadata extracted from new samples.

Added a Python script to scan and process README.md files.
• Implemented features to clean existing tables and replace them with
updated content.
•  Linked the script functionality to Linear ticket DOC-16 for tracking.

---------

Co-authored-by: Daniel Abraham <[email protected]>
  • Loading branch information
mario99logic and daabr authored Jan 3, 2025
1 parent f604b2c commit 43e9afc
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 25 deletions.
62 changes: 37 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,42 @@ projects for:
In addition, the [samples](./samples/) directory contains projects that
demonstrate basic system features, integration APIs, and best practices.

| Name | Description | Integrations |
| :------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------- | :------------------------------------------ |
| 🐍 [AWS Health to Slack](./aws_health_to_slack/) | Announce cloud health events based on a resource ownership mapping | AWS (Health), Google Sheets, Slack |
| 🐍 [Break-glass](./break_glass/) | Manage break-glass requests and approvals for temporary elevated permissions | Slack &rarr; AWS (IAM), Jira |
| 🐍 [Categorize emails](./categorize_emails/) | Categorize new emails and notify the appropriate channels based on the content | Gmail &rarr; ChatGPT &rarr; Slack |
| 🐍 [Confluence to Slack](./confluence_to_slack/) | Notify when a new page with a specific label is created | Confluence &rarr; Slack |
| 🐍 [Data pipeline](./data_pipeline/) | Process and store data from new S3 files in a database | AWS (SNS, S3) &rarr; SQLite |
| 🐍 [Discord to Spreadsheet](./discord_to_spreadsheet/) | Log Discord message events using AutoKitteh's event system for simple event handling | Discord &rarr; Google Sheets |
| 🐍 [GitHub Actions](./github_actions/) | Trigger GitHub workflows across repos using AutoKitteh’s event system for automation | GitHub |
|[GitHub Copilot seats](./github_copilot/) | Automate daily GitHub Copilot user pruning and report changes | GitHub &harr; Slack |
| 🐍 [Google Calendar to Asana](./google_cal_to_asana/) | Create an Asana task whenever a new event is added to Google Calendar | Google Calendar &rarr; Asana |
| 🐍 [Google Forms to Jira](./google_forms_to_jira/) | Create Jira issues based on Google Forms responses | Google Forms &rarr; Jira |
| 🐍 [Jira assignee from schedule](./jira_google_calendar/assignee_from_schedule/) | Assign new Jira issues to the current on-caller based on a schedule in a shared calendar | Jira &harr; Google Calendar |
| 🐍 [Jira deadline to event](./jira_google_calendar/deadline_to_event/) | Create/update calendar events based on the deadlines of Jira issues | Jira &harr; Google Calendar |
| 🐍 [Quickstart](./quickstart/) | Basic workflow for tutorials | HTTP |
|[ReviewKitteh](./reviewkitteh/) | Monitor pull requests, and meow at random people | GitHub, Google Sheets, Slack |
| 🐍 [Room reservation](./room_reservation/) | Manage via Slack ad-hoc room reservations in Google Calendar | Slack &harr; Google Calendar, Google Sheets |
| 🐍 [Slack Discord sync](./slack_discord_sync) | Sync Slack and Discord messages in real-time | Slack &harr; Discord |
| 🐍 [Slack support](./slack_support/) | Categorize Slack support requests using AI, and route them to the appropriate people | Slack &harr; Gemini, Google Sheets |
| 🐍 [Task chain](./task_chain/) | Run a sequence of tasks with fault tolerance | Slack |
| 🐍 [Webhook to Jira](./webhook_to_jira/) | Create Jira issues based on HTTP GET/POST requests | HTTP &rarr; Jira |

> [!NOTE]
> 🐍 = Python implementation, ⭐ = Starlark implementation.
<!-- START-TABLE -->
| Name | Description | Integration |
| :--- | :---------- | :---------- |
| [Copy Auth0 Users to HubSpot](./auth0_to_hubspot/) | Periodically add new Auth0 users to HubSpot as contacts | auth0, hubspot |
| [AWS Health to Slack](./aws_health_to_slack/) | Monitor AWS health events | aws, slack, sheets |
| [Manage emergency AWS access requests via Slack](./break_glass/) | Submit emergency AWS access requests via Slack, which are then approved or denied based on a set of predefined conditions | aws, slack |
| [Slack notify on categorized email](./categorize_emails/) | Categorizes incoming emails and notifies relevant Slack channels by integrating Gmail, ChatGPT, and Slack | gmail, slack, chatgpt |
| [Slack notify on Confluence page created](./confluence_to_slack/) | When Confluence page is created the user will be notified on Slack | confluence, slack |
| [Parse a file in S3 and insert to database](./data_pipeline/) | Triggered by a new GPX file on an S3 bucket, the pipeline code will parse the GPX file and insert it into a database. | aws, http, sqlite3 |
| [Github Actions](./github_actions/) | GitHub workflows that interact across multiple repositories | github |
| [Unregister non active users from Copilot](./github_copilot_seats/) | If Copilot was not used in a preceding period by users, the workflow automatically unregisters and notifies them. Users can ask for their subscription to be reinstated. | githubcopilot, slack |
| [Google Calendar To Asana](./google_cal_to_asana/) | Creates Asana tasks based on Google Calendar events | calendar, asana |
| [Create Jira ticket from Google form](./google_forms_to_jira/) | Trigger by HTTP request, continue polling Google forms, and create Jira ticket based on the form's data | forms, http, jira |
| [Hacker News Alerts in Slack ](./hackernews/) | Track Hacker News articles by topic and send updates to Slack | slack |
| [JIRA Assignee From Google Calendar Workflow](./jira_google_calendar/assignee_from_schedule/) | Set Assignee in Jira ticket to the person currently on-call | jira, calendar |
| [Create calendar due date event for Jira ticket](./jira_google_calendar/deadline_to_event/) | When a new Jira issue is created, the workflow automatically generates a Google Calendar event with a deadline | calendar, jira |
| [Pull Request Review Reminder (Purrr)](./purrr/) | Streamline code reviews and cut down turnaround time to merge pull requests | GitHub, Google Sheets, Slack |
| [Quickstart](./quickstart/) | Sample for quickstart | http |
| [Monitor PR until completion in Slack](./reviewkitteh/) | Create a Slack channel for each PR, update team leads until completion | slack, github, sheets |
| [Ad-hoc room reservation via Slack](./room_reservation/) | Ad-hoc room reservation via Slack slash commands | slack, calendar |
| [Jira](./samples/atlassian/jira/) | Samples using Jira APIs | jira |
| [GitHub](./samples/github/) | Samples using GitHub APIs | github |
| [Google Calendar](./samples/google/calendar/) | Samples using Google Calendar APIs | calendar |
| [Google Forms](./samples/google/forms/) | Samples using Google Forms APIs | forms |
| [Gemini](./samples/google/gemini/) | Simple usage of the Gemini API | gemini |
| [Gmail](./samples/google/gmail/) | Samples using Gmail APIs | gmail |
| [Google Sheets](./samples/google/sheets/) | Samples using Google Sheets APIs | sheets |
| [HTTP](./samples/http/) | Samples using HTTP requests and webhooks | http |
| [OpenAI ChatGPT](./samples/openai_chatgpt/) | Samples using chatGPT APIs | chatgpt |
| [Runtime Events](./samples/runtime_events/) | Samples using events in AutoKitteh - subscribe(), next_event(), unsubscribe() | autokitteh |
| [Scheduler](./samples/scheduler/) | Samples using cron scheduler for workflows | scheduler |
| [Slack](./samples/slack/) | Samples using Slack APIs | slack |
| [Twilio](./samples/twilio/) | Samples using Twilio APIs | twilio |
| [Slack bot for assistance requests with AI categorization](./slack_support/) | Slack bot request for assistance is inferred using Google's Gemini AI. The appropriate person is mentioned according to a predetermined table of expertise in a Google Doc. The person can then `!take` the request and later `!resolve` it. | slack, googlegemini |
| [Fault tolerant workflow with manual Slack approvals](./task_chain/single_workflow/basic/) | Runs a sequence of tasks with fault tolerance. In case of failure, user can decide to terminate or retry from the point of failure. | slack |
| [Create Jira Ticket from a Webhook data](./webhook_to_jira/) | Create Jira Ticket from a Webhook data | jira, http |
<!-- END-TABLE -->

<img width="451" alt="image" src="https://github.com/user-attachments/assets/f556279f-40a4-4df2-93ef-e1838fcb9861">
59 changes: 59 additions & 0 deletions update_projects_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Extract metadata from project README files to update table in repo's README file."""

from pathlib import Path
import re


ROOT_PATH = Path(__file__).parent


def generate_readme_table(folder_path: Path) -> list[str]:
"""Generate a list of table rows from README metadata."""
rows = []
for readme_file in sorted(folder_path.rglob("README.md")):
metadata = extract_metadata(readme_file)
rows.append(to_table_row(readme_file.parent, metadata))
return [row for row in rows if row] # Remove empty rows.


def extract_metadata(readme_file: Path) -> dict:
"""Extract metadata from a project's README file."""
field_pattern = r"^([a-z]+):\s+(.+)" # "key: value"
f = readme_file.read_text(encoding="utf-8")
metadata = {}

for k, v in re.findall(field_pattern, f, re.MULTILINE):
# Integrations value is a list of strings, others are just strings.
metadata[k] = re.findall(r'"(.+?)"', v) if k == "integrations" else v

return metadata


def to_table_row(project_dir: Path, metadata: dict) -> str:
"""Convert metadata into a markdown table row."""
title = metadata.get("title", "")
if not title:
return ""

description = metadata.get("description", "")
integrations = ", ".join(metadata.get("integrations", []))
path = project_dir.relative_to(ROOT_PATH)

return f"| [{title}](./{path}/) | {description} | {integrations} |\n"


def insert_rows_to_table(readme_file: Path, new_rows: list[str]) -> None:
"""Insert rows into the table section of the README file."""
md = readme_file.read_text(encoding="utf-8")
table = "-->\n| Name | Description | Integration |\n| :--- | :---------- | :---------- |\n"

for row in new_rows:
table += row

md = re.sub("-->.+<!--", table + "<!--", md, flags=re.DOTALL)
readme_file.write_text(md, encoding="utf-8")


if __name__ == "__main__":
new_rows = generate_readme_table(ROOT_PATH)
insert_rows_to_table(ROOT_PATH / "README.md", new_rows)
129 changes: 129 additions & 0 deletions update_projects_table_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from pathlib import Path
import tempfile
import unittest

import update_projects_table


class TestCreateProjectTable(unittest.TestCase):
"""Unit tests for the "update_projects_table" module."""

def generic_test_extract_metadata(self, input, expected):
actual = {}
with tempfile.NamedTemporaryFile(delete_on_close=False) as f:
if input:
f.write(input)
f.close()
actual = update_projects_table.extract_metadata(Path(f.name))
self.assertEqual(expected, actual)

def test_extract_metadata(self):
# Empty metadata.
self.generic_test_extract_metadata(None, {})

# Single basic field.
self.generic_test_extract_metadata(b"foo: bar", {"foo": "bar"})

# Single basic field with extra whitespaces (a recoverable typo).
self.generic_test_extract_metadata(b"foo: bar\n\n\n", {"foo": "bar"})

# Multiple basic fields.
input = b"aaa: 111\nbbb: 222\nccc: 333\n"
expected = {"aaa": "111", "bbb": "222", "ccc": "333"}
self.generic_test_extract_metadata(input, expected)

# Empty integrations field.
self.generic_test_extract_metadata(b"integrations:", {})
self.generic_test_extract_metadata(b"integrations: ", {})
self.generic_test_extract_metadata(b"integrations: []", {"integrations": []})

# Non-empty integrations field.
input = b'integrations: ["a"]'
expected = {"integrations": ["a"]}
self.generic_test_extract_metadata(input, expected)

input = b'integrations: ["a",]'
expected = {"integrations": ["a"]}
self.generic_test_extract_metadata(input, expected)

input = b'integrations: ["a","b"]'
expected = {"integrations": ["a", "b"]}
self.generic_test_extract_metadata(input, expected)

input = b'integrations: ["a", "b"]'
expected = {"integrations": ["a", "b"]}
self.generic_test_extract_metadata(input, expected)

# Combination of basic and integrations fields.
input = b'a: 1\nb: 2\nintegrations: ["c", "d"]\ne: 3'
expected = {"a": "1", "b": "2", "integrations": ["c", "d"], "e": "3"}
self.generic_test_extract_metadata(input, expected)

def test_to_table_row(self):
# Metadata without a title - ignore it.
metadata = {"description": "d", "integrations": ["1"]}
actual = update_projects_table.to_table_row(Path("dir").absolute(), metadata)
self.assertEqual("", actual)

# Metadata with nothing but the title.
metadata = {"title": "blah blah blah"}
expected = "| [blah blah blah](./dir/) | | |\n"
actual = update_projects_table.to_table_row(Path("dir").absolute(), metadata)
self.assertEqual(expected, actual)

# Regular project with a single integration.
metadata = {"title": "t", "description": "d", "integrations": ["1"]}
expected = "| [t](./dir/) | d | 1 |\n"
actual = update_projects_table.to_table_row(Path("dir").absolute(), metadata)
self.assertEqual(expected, actual)

# Regular project with multiple integrations.
metadata = {"title": "t", "description": "d", "integrations": ["1", "2", "3"]}
expected = "| [t](./dir/) | d | 1, 2, 3 |\n"
actual = update_projects_table.to_table_row(Path("dir").absolute(), metadata)
self.assertEqual(expected, actual)

def generic_test_insert_rows_to_table(self, num_rows, expected):
actual = ""
with tempfile.NamedTemporaryFile(delete_on_close=False) as f:
f.write(b"prefix\n<!--start-table-->\ngarbage\n<!--end-table-->\nsuffix\n")
f.close()
rows = ["| row |\n"] * num_rows
update_projects_table.insert_rows_to_table(Path(f.name), rows)
actual = Path(f.name).read_text()
self.assertEqual(expected, actual)

def test_insert_rows_to_table_empty(self):
expected = (
"prefix\n<!--start-table-->\n"
"| Name | Description | Integration |\n"
"| :--- | :---------- | :---------- |\n"
"<!--end-table-->\nsuffix\n"
)
self.generic_test_insert_rows_to_table(0, expected)

def test_insert_rows_to_table_one_row(self):
expected = (
"prefix\n<!--start-table-->\n"
"| Name | Description | Integration |\n"
"| :--- | :---------- | :---------- |\n"
"| row |\n"
"<!--end-table-->\nsuffix\n"
)
self.generic_test_insert_rows_to_table(1, expected)

def test_insert_rows_to_table_multiple_rows(self):
expected = (
"prefix\n<!--start-table-->\n"
"| Name | Description | Integration |\n"
"| :--- | :---------- | :---------- |\n"
"| row |\n"
"| row |\n"
"| row |\n"
"<!--end-table-->\nsuffix\n"
)
self.generic_test_insert_rows_to_table(3, expected)


if __name__ == "__main__":
unittest.main()

0 comments on commit 43e9afc

Please sign in to comment.