From 6ed7a0aa8e57e4145fd50b7e603559f7cc602ac6 Mon Sep 17 00:00:00 2001 From: robbykap Date: Fri, 13 Feb 2026 12:16:07 -0700 Subject: [PATCH 01/32] Major refactor for notification system --- .../send-course-notification/action.yml | 63 +++++ .../actions/send-pypi-notification/action.yml | 54 +++++ .../actions/setup-notifications/action.yml | 21 ++ .github/workflows/docker_automation.yaml | 49 +--- .github/workflows/mdxcanvas_automation.yaml | 53 ++--- .github/workflows/poetry_publish.yaml | 44 +--- course_updates/canvas_notification.py | 109 --------- course_updates/docker_notification.py | 95 -------- course_updates/send_course_notification.py | 223 ------------------ notifications/__init__.py | 0 .../fallback.py | 0 notifications/formatting/__init__.py | 0 notifications/formatting/canvas_format.py | 79 +++++++ notifications/formatting/docker_format.py | 79 +++++++ notifications/formatting/formatting_utils.py | 100 ++++++++ notifications/formatting/pypi_format.py | 37 +++ notifications/formatting/style.md | 14 ++ notifications/resources.py | 39 +++ notifications/send_course.py | 60 +++++ notifications/send_notification.py | 155 ++++++++++++ notifications/send_pypi.py | 41 ++++ .../build_send_update_notification_docker.sh | 21 -- pypi_updates/send_update_notification.py | 124 ---------- tests/test_chunking.py | 206 ++++++++++++++++ 24 files changed, 992 insertions(+), 674 deletions(-) create mode 100644 .github/actions/send-course-notification/action.yml create mode 100644 .github/actions/send-pypi-notification/action.yml create mode 100644 .github/actions/setup-notifications/action.yml delete mode 100644 course_updates/canvas_notification.py delete mode 100644 course_updates/docker_notification.py delete mode 100644 course_updates/send_course_notification.py create mode 100644 notifications/__init__.py rename course_updates/create_fallback.py => notifications/fallback.py (100%) create mode 100644 notifications/formatting/__init__.py create mode 100644 notifications/formatting/canvas_format.py create mode 100644 notifications/formatting/docker_format.py create mode 100644 notifications/formatting/formatting_utils.py create mode 100644 notifications/formatting/pypi_format.py create mode 100644 notifications/formatting/style.md create mode 100644 notifications/resources.py create mode 100644 notifications/send_course.py create mode 100644 notifications/send_notification.py create mode 100644 notifications/send_pypi.py delete mode 100644 pypi_updates/build_send_update_notification_docker.sh delete mode 100644 pypi_updates/send_update_notification.py create mode 100644 tests/test_chunking.py diff --git a/.github/actions/send-course-notification/action.yml b/.github/actions/send-course-notification/action.yml new file mode 100644 index 0000000..219c66a --- /dev/null +++ b/.github/actions/send-course-notification/action.yml @@ -0,0 +1,63 @@ +name: Send Course Notification +description: Create fallback output if needed, get GitHub avatar, and send course notification to Discord + +inputs: + notification-type: + description: "Type of notification: 'canvas' or 'docker'" + required: true + output-type: + description: "Output type for fallback: 'MDXCanvas' or 'Docker'" + required: true + payload-path: + description: "Path to the output JSON file" + required: true + stdout-log: + description: "Path to stdout log file" + required: true + stderr-log: + description: "Path to stderr log file" + required: true + course-id: + description: "Course ID" + required: true + discord-role: + description: "Discord CI/CD role ID" + required: true + discord-webhook-url: + description: "Discord webhook URL" + required: true + +runs: + using: composite + steps: + - name: Create fallback output if needed + shell: bash + run: | + PYTHONPATH=utils python -m notifications.fallback \ + --output-type "${{ inputs.output-type }}" \ + --output-path "${{ inputs.payload-path }}" \ + --stdout-log "${{ inputs.stdout-log }}" \ + --stderr-log "${{ inputs.stderr-log }}" \ + --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + - name: Get user avatar + id: avatar + shell: bash + run: | + AVATAR_URL=$(curl -s "https://api.github.com/users/${{ github.actor }}" | jq -r '.avatar_url') + echo "url=$AVATAR_URL" >> $GITHUB_OUTPUT + + - name: Send course notification to Discord + shell: bash + env: + DISCORD_WEBHOOK_URL: ${{ inputs.discord-webhook-url }} + run: | + PYTHONPATH=utils python -m notifications.send_course \ + --type "${{ inputs.notification-type }}" \ + --payload "${{ inputs.payload-path }}" \ + --course-id "${{ inputs.course-id }}" \ + --author "${{ github.actor }}" \ + --author-icon "${{ steps.avatar.outputs.url }}" \ + --branch "${{ github.ref }}" \ + --cicd-id "${{ inputs.discord-role }}" \ + --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/actions/send-pypi-notification/action.yml b/.github/actions/send-pypi-notification/action.yml new file mode 100644 index 0000000..40be3a1 --- /dev/null +++ b/.github/actions/send-pypi-notification/action.yml @@ -0,0 +1,54 @@ +name: Send PyPI Notification +description: Get GitHub avatar and send PyPI update notification to Discord + +inputs: + pypi-package: + description: "PyPI package name" + required: true + success: + description: "Whether the publish succeeded ('true' or 'false')" + required: true + toml-updated: + description: "Whether pyproject.toml version was bumped ('true' or 'false')" + required: true + version: + description: "Package version" + required: true + discord-role: + description: "Discord CI/CD role ID" + required: true + discord-webhook-url: + description: "Discord webhook URL" + required: true + +runs: + using: composite + steps: + - name: Get user avatar + id: avatar + shell: bash + run: | + AVATAR_URL=$(curl -s "https://api.github.com/users/${{ github.actor }}" | jq -r '.avatar_url') + echo "url=$AVATAR_URL" >> $GITHUB_OUTPUT + + - name: Send PyPI notification to Discord + shell: bash + env: + DISCORD_WEBHOOK_URL: ${{ inputs.discord-webhook-url }} + run: | + CMD="PYTHONPATH=utils python -m notifications.send_pypi \ + --type ${{ inputs.pypi-package }} \ + --author ${{ github.actor }} \ + --author-icon ${{ steps.avatar.outputs.url }} \ + --action-url https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + if [ '${{ inputs.success }}' == 'true' ] && \ + [ '${{ inputs.toml-updated }}' == 'true' ]; then + CMD="$CMD --success 1" + fi + + CMD="$CMD --version ${{ inputs.version }} \ + --cicd-id ${{ inputs.discord-role }}" + + echo "$CMD" + eval "$CMD" diff --git a/.github/actions/setup-notifications/action.yml b/.github/actions/setup-notifications/action.yml new file mode 100644 index 0000000..5dc3137 --- /dev/null +++ b/.github/actions/setup-notifications/action.yml @@ -0,0 +1,21 @@ +name: Setup Notifications +description: Checkout utils repo and install discord-webhook plus any extra packages + +inputs: + extra-packages: + description: "Additional pip packages to install (e.g. mdxcanvas==0.3.27)" + required: false + default: "" + +runs: + using: composite + steps: + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils + path: utils + + - name: Install notification dependencies + shell: bash + run: pip install discord-webhook ${{ inputs.extra-packages }} diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index 0a57452..e4ec75e 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -31,11 +31,8 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Checkout utils repo - uses: actions/checkout@v4 - with: - repository: BYU-CS-Course-Ops/utils - path: utils + - name: Setup notifications + uses: ./.github/actions/setup-notifications - name: Get changed files run: | @@ -72,35 +69,15 @@ jobs: --root-dir "${{ github.workspace }}" \ > "$STDOUT_LOG" 2> "$STDERR_LOG" - - name: Create fallback output if needed + - name: Send course notification if: always() - run: | - python utils/course_updates/create_fallback.py \ - --output-type Docker \ - --output-path "${{ github.workspace }}/.github/logs/docker_output.json" \ - --stdout-log "${{ github.workspace }}/.github/logs/docker_stdout.log" \ - --stderr-log "${{ github.workspace }}/.github/logs/docker_stderr.log" \ - --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - - - name: Get user avatar - run: | - AVATAR_URL=$(curl -s https://api.github.com/users/${{ github.actor }} | jq -r '.avatar_url') - echo "AVATAR_URL=$AVATAR_URL" >> $GITHUB_ENV - - - name: Install discord_webhook - run: | - pip install discord-webhook - - - name: Send Discord Notification - env: - DISCORD_WEBHOOK_URL: ${{ secrets.discord_webhook_url }} - run: | - python utils/course_updates/send_course_notification.py \ - --type "docker" \ - --payload "${{ github.workspace }}/.github/logs/docker_output.json" \ - --course-id "${{ inputs.course_id }}" \ - --author "${{ github.actor }}" \ - --author-icon "$AVATAR_URL" \ - --branch "${{ github.ref }}" \ - --cicd-id "${{ secrets.discord_role }}" \ - --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ No newline at end of file + uses: ./.github/actions/send-course-notification + with: + notification-type: docker + output-type: Docker + payload-path: ${{ github.workspace }}/.github/logs/docker_output.json + stdout-log: ${{ github.workspace }}/.github/logs/docker_stdout.log + stderr-log: ${{ github.workspace }}/.github/logs/docker_stderr.log + course-id: ${{ inputs.course_id }} + discord-role: ${{ secrets.discord_role }} + discord-webhook-url: ${{ secrets.discord_webhook_url }} diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index f354e2a..9465fd1 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -49,16 +49,13 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Checkout utils repo - uses: actions/checkout@v4 + - name: Setup notifications + uses: ./.github/actions/setup-notifications with: - repository: BYU-CS-Course-Ops/utils - path: utils + extra-packages: mdxcanvas==${{ inputs.mdxcanvas_version }} - - name: Setup - run: | - mkdir -p "$LOGS_DIR" - pip install mdxcanvas==${{ inputs.mdxcanvas_version }} discord-webhook + - name: Create logs directory + run: mkdir -p "$LOGS_DIR" - name: Run MDXCanvas id: mdxcanvas @@ -80,36 +77,22 @@ jobs: > "$LOGS_DIR/mdxcanvas.stdout.log" \ 2> "$LOGS_DIR/mdxcanvas.stderr.log" - - name: Process results and notify - env: - DISCORD_WEBHOOK_URL: ${{ secrets.discord_webhook_url }} + - name: Print debug logs run: | echo "=== MDXCanvas STDOUT ===" cat "$LOGS_DIR/mdxcanvas.stdout.log" - + echo "=== MDXCanvas STDERR ===" cat "$LOGS_DIR/mdxcanvas.stderr.log" - - # Create fallback if needed - python utils/course_updates/create_fallback.py \ - --output-type MDXCanvas \ - --output-path "$OUTPUT_PATH" \ - --stdout-log "$LOGS_DIR/mdxcanvas.stdout.log" \ - --stderr-log "$LOGS_DIR/mdxcanvas.stderr.log" \ - --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - echo "=== Updated Output ===" - cat "$OUTPUT_PATH" - - # Get avatar and send notification - AVATAR_URL=$(curl -s "https://api.github.com/users/${{ github.actor }}" | jq -r '.avatar_url') - - python utils/course_updates/send_course_notification.py \ - --type "canvas" \ - --payload "$OUTPUT_PATH" \ - --course-id "${{ inputs.course_id }}" \ - --author "${{ github.actor }}" \ - --author-icon "$AVATAR_URL" \ - --branch "${{ github.ref }}" \ - --cicd-id "${{ secrets.discord_role }}" \ - --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ No newline at end of file + - name: Send course notification + uses: ./.github/actions/send-course-notification + with: + notification-type: canvas + output-type: MDXCanvas + payload-path: ${{ env.OUTPUT_PATH }} + stdout-log: ${{ env.LOGS_DIR }}/mdxcanvas.stdout.log + stderr-log: ${{ env.LOGS_DIR }}/mdxcanvas.stderr.log + course-id: ${{ inputs.course_id }} + discord-role: ${{ secrets.discord_role }} + discord-webhook-url: ${{ secrets.discord_webhook_url }} diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 47a34e8..7108495 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -31,7 +31,7 @@ jobs: version: ${{ needs.check-toml-version.outputs.version }} steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Poetry and Version Plugin run: pip install "poetry<2" poetry-version-from-file @@ -66,35 +66,17 @@ jobs: notify-discord: needs: [check-toml-version, poetry-publish] runs-on: ubuntu-latest - if: always() # Notify on both success and failure + if: always() steps: - - name: Get User Avatar URL - id: avatar - run: | - AVATAR_URL=$(curl -s https://api.github.com/users/${{ github.actor }} | jq -r '.avatar_url') - echo "avatar_url=$AVATAR_URL" >> $GITHUB_ENV - - - name: Send notification to Discord - run: | - CMD="python /scripts/send_update_notification.py \ - --type ${{ inputs.pypi_package }} \ - --author ${{ github.actor }} \ - --author-icon ${{ env.avatar_url }} \ - --action-url https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - - if [ '${{ needs.poetry-publish.outputs.success }}' == 'true' ] && \ - [ '${{ needs.check-toml-version.outputs.uped_toml }}' == 'true' ]; then - CMD="$CMD --success 1" - fi - - CMD="$CMD --version ${{ needs.poetry-publish.outputs.version }} \ - --cicd-id ${{ secrets.discord_role }}" - - echo "$CMD" + - name: Setup notifications + uses: ./.github/actions/setup-notifications - docker run --rm \ - -v ${{ github.workspace }}:/repo \ - -w /repo \ - -e DISCORD_WEBHOOK_URL=${{ secrets.discord_webhook_url }} \ - byucscourseops/send_update_notification:latest \ - sh -c "$CMD" + - name: Send PyPI notification + uses: ./.github/actions/send-pypi-notification + with: + pypi-package: ${{ inputs.pypi_package }} + success: ${{ needs.poetry-publish.outputs.success }} + toml-updated: ${{ needs.check-toml-version.outputs.uped_toml }} + version: ${{ needs.poetry-publish.outputs.version }} + discord-role: ${{ secrets.discord_role }} + discord-webhook-url: ${{ secrets.discord_webhook_url }} diff --git a/course_updates/canvas_notification.py b/course_updates/canvas_notification.py deleted file mode 100644 index 1e643f7..0000000 --- a/course_updates/canvas_notification.py +++ /dev/null @@ -1,109 +0,0 @@ -from datetime import datetime -from send_course_notification import Field, space, generate_field, truncate_error_message - -''' -Example payload (mdxcanvas -v 0.3.27): - -{ - "deployed_content": [ - [ - "file", - "beanlab.png", - null - ], - [ - "page", - "Example Page", - "https://byu.instructure.com/courses/20736/pages/example-page-13" - ] - ], - "content_to_review": [ - [ - "HW 7a - Set Performance", - "https://byu.instructure.com/courses/27547/quizzes/493001" - ] - ], - "error": "" -} -''' - -def requires_canvas_review(data) -> bool: - """ - Check if there is content to review in the payload. - This is used to determine if we should send a role mention. - """ - return data["content_to_review"] or data["error"] - - -def check_canvas_payload(data) -> bool: - """ - More specific check if we add more content types to the payload. - The notification is only sent if there is something to deploy, - review or if there is an error. - """ - return ( - data['deployed_content'] - or data['content_to_review'] - or data['error'] - ) - - -def canvas_format(data, course_id, author, author_icon, branch, action_url): - deployed_content = ( - '\n'.join(f'- **{rtype}**: [{content}]({link})' if link else f'- **{rtype}**: {content}' - for rtype, content, link in data['deployed_content'])) \ - if data['deployed_content'] \ - else '*No items deployed*' - - content_to_review = ( - '\n'.join(f'- [{dat[0]}]({dat[1]})' - for dat in data['content_to_review'])) \ - if data['content_to_review'] \ - else '*No items to review*' - - error = data["error"] if data['error'] else '*No errors*' - if error != '*No errors*': - error = truncate_error_message(error) - - return { - "username": "Canvas Notifications", - "avatar_url": "https://tinyurl.com/ek4ytkan", - "embeds": [{ - "author": {"name": author, "icon_url": author_icon}, - "title": f"CS {course_id} - Course Updates", - "description": f'**`{branch}`**', - "color": 15861021, - "fields": [ - space(), - *generate_field( - name='**Deployed Content:**', - value=deployed_content, - inline=False - ), - space(), - *generate_field( - name='**Content to Review:**', - value=content_to_review, - inline=False - ), - space(), - *generate_field( - name='**Error:**', - value=error, - inline=False - ), - space(), - Field( - name=f'**GitHub Action:**', - value=f'[View here]({action_url})', - inline=False - ), - space() - ], - "timestamp": datetime.now().isoformat(), - "footer": { - "text": "MDXCanvas GitHub Action", - "icon_url": "https://tinyurl.com/4ky2afzx" - } - }] - } diff --git a/course_updates/docker_notification.py b/course_updates/docker_notification.py deleted file mode 100644 index 271100b..0000000 --- a/course_updates/docker_notification.py +++ /dev/null @@ -1,95 +0,0 @@ -from datetime import datetime -from send_course_notification import Field, space, generate_field, truncate_error_message - -''' -Example payload (15 Apr 2025 - docker_notification.py): - -{ - "updated_images": [], - "failed_images": [], - "error": "" -} -''' - - -def requires_docker_review(data) -> bool: - """ - Check if there is content to review in the payload. - This is used to determine if we should send a role mention. - """ - return data["failed_images"] or data["error"] - - -def check_docker_payload(data) -> bool: - """ - More specific check if we add more content types to the payload. - The notification is only sent if there is an updated image, failed - image or if there is an error. - """ - return ( - data['updated_images'] - or data['failed_images'] - or data['error'] - ) - - -def docker_format(data, course_id, author, author_icon, branch, action_url): - updated_images = ( - '\n'.join(f'- {image}' - for image in data['updated_images'])) \ - if data['updated_images'] \ - else '*No updated images*' - - - failed_images = ( - '\n'.join(f'- {image}' - for image in data['failed_images'])) \ - if data['failed_images'] \ - else '*No items to review*' - - error = data["error"] if data['error'] else '*No errors*' - if error != '*No errors*': - error = truncate_error_message(error) - - return { - "username": "Gradescope Notifications", - "avatar_url": "https://tinyurl.com/mr2fyjse", - "embeds": [{ - "author": {"name": author, "icon_url": author_icon}, - "title": f"CS {course_id} - Docker Updates", - "description": f'**`{branch}`**', - "color": 238076, - "fields": [ - space(), - *generate_field( - name='**Updated Image(s):**', - value=updated_images, - inline=True - ), - space(), - *generate_field( - name='**Failed Image(s):**', - value=failed_images, - inline=True - ), - space(), - *generate_field( - name='**Error:**', - value=error, - inline=False - ), - space(), - Field( - name=f'**GitHub Action:**', - value=f'[View here]({action_url})', - inline=False - ), - space() - ], - "timestamp": datetime.now().isoformat(), - "footer": { - "text": "Docker GitHub Action", - "icon_url": "https://tinyurl.com/32ffdfss" - } - }] - } diff --git a/course_updates/send_course_notification.py b/course_updates/send_course_notification.py deleted file mode 100644 index 006afca..0000000 --- a/course_updates/send_course_notification.py +++ /dev/null @@ -1,223 +0,0 @@ -from argparse import ArgumentParser -import json -import os -from typing import TypedDict -from discord_webhook import DiscordWebhook, DiscordEmbed - - -class Field(TypedDict): - name: str - value: str - inline: bool - - -def space(inline=False) -> Field: - return Field(name="\u200b", value="\u200b", inline=inline) - - -def generate_field(name: str, value: str, inline: bool = False) -> list[Field]: - # Check if this is a code block - if value.startswith('```') and value.endswith('```'): - # Extract content from code block - lines = value.split('\n', 1) - if len(lines) > 1: - # Has content after opening ``` - rest = lines[1].rsplit('\n```', 1)[0] # Remove closing ``` - - if len(rest) > 1000: # Leave room for ``` markers - # Split content and wrap each chunk in code block - chunks = [] - chunk_size = 1000 - for i in range(0, len(rest), chunk_size): - chunk = rest[i:i + chunk_size] - chunks.append(f"```\n{chunk}\n```") - - return [ - Field( - name=name if i == 0 else f"{name} (continued)", - value=chunk_value, - inline=inline - ) for i, chunk_value in enumerate(chunks) - ] - - # Original logic for non-code-block values - if len(value) > 1024: - chunks = [value[i:i + 1024] for i in range(0, len(value), 1024)] - return [ - Field( - name=name if i == 0 else f"{name} (continued)", - value=chunk, - inline=inline - ) for i, chunk in enumerate(chunks) - ] - else: - return [Field(name=name, value=value, inline=inline)] - - -def truncate_error_message(error: str, max_chars: int = 900) -> str: - """ - Extracts the most relevant error information for Discord: - - If a traceback exists, keep only the last traceback - - Trim to the final few stack frames - - Always include the exception message - """ - - if not error: - return f"```\nNo error output available.\n```" - - lines = error.splitlines() - - # Find all traceback starts - traceback_indices = [ - i for i, line in enumerate(lines) - if line.strip().startswith("Traceback (most recent call last):") - ] - - if traceback_indices: - # Use only the LAST traceback - tb_start = traceback_indices[-1] - relevant = lines[tb_start:] - - # Keep only the last N stack frames + exception - # Stack frames usually come in pairs: File + code line - MAX_LINES = 12 - if len(relevant) > MAX_LINES: - relevant = ["... (traceback truncated) ..."] + relevant[-MAX_LINES:] - - message = "\n".join(relevant) - else: - # No traceback → take last meaningful chunk - message = "\n".join(lines[-15:]) - - # Hard cap for Discord - if len(message) > max_chars: - message = message[-max_chars:] - message = "... (truncated) ...\n" + message - - return f"```\n{message}\n```" - - - -def send_parsed_discord_embed(webhook_url: str, notification: dict, requires_review:bool, cicd_id: int = None): - webhook = DiscordWebhook( - url=webhook_url, - username=notification.get("username"), - avatar_url=notification.get("avatar_url"), - content=f"<@&{cicd_id}>" if requires_review and cicd_id else None, - ) - - for embed_data in notification.get("embeds", []): - embed = DiscordEmbed( - title=embed_data.get("title"), - description=embed_data.get("description"), - color=embed_data.get("color"), - timestamp=embed_data.get("timestamp") - ) - - # Optional author - author = embed_data.get("author", {}) - if author: - embed.set_author( - name=author.get("name", ""), - icon_url=author.get("icon_url", "") - ) - - # Optional footer - footer = embed_data.get("footer", {}) - if footer: - embed.set_footer( - text=footer.get("text", ""), - icon_url=footer.get("icon_url", "") - ) - - # Fields - for field in embed_data.get("fields", []): - field_name = field.get("name", "\u200b") - field_value = field.get("value", "\u200b") - - # Ensure neither name nor value is empty (Discord requirement) - if not field_name or not field_name.strip(): - field_name = "\u200b" - if not field_value or not field_value.strip(): - field_value = "\u200b" - - embed.add_embed_field( - name=field_name, - value=field_value, - inline=field.get("inline", False) - ) - - webhook.add_embed(embed) - - # Calculate approximate embed size - total_size = 0 - for embed_data in notification.get("embeds", []): - total_size += len(str(embed_data.get("title", ""))) - total_size += len(str(embed_data.get("description", ""))) - for field in embed_data.get("fields", []): - total_size += len(field.get("name", "")) - total_size += len(field.get("value", "")) - - if total_size > 4800: # 80% of 6000 limit - print(f"⚠️ Warning: Embed size ({total_size} chars) approaching Discord's 6000 char limit") - if total_size > 6000: - print(f"❌ Error: Embed size ({total_size} chars) exceeds Discord's 6000 char limit") - - response = webhook.execute() - if response.status_code >= 400: - print(f"❌ Discord returned status {response.status_code}: {response.text}") - else: - print("✅ Sent message successfully.") - - -def main(ntype, payload, course_id, author, author_icon, branch_name, action_url, cicd_id): - webhook_url = os.getenv("DISCORD_WEBHOOK_URL") - if not webhook_url: - raise EnvironmentError("DISCORD_WEBHOOK_URL environment variable is not set.") - - # Import formatter and checker - if ntype == "canvas": - from canvas_notification import canvas_format, check_canvas_payload, requires_canvas_review - format_notification = canvas_format - has_info = check_canvas_payload - requires_review = requires_canvas_review - elif ntype == "docker": - from docker_notification import docker_format, check_docker_payload, requires_docker_review - format_notification = docker_format - has_info = check_docker_payload - requires_review = requires_docker_review - else: - raise ValueError("Invalid notification type. Use 'canvas' or 'docker'.") - - with open(payload, 'r') as file: - data = json.load(file) - - if not data or not has_info(data): - print("No information to send.") - return - - notification = format_notification( - data=data, - course_id=course_id, - author=author, - author_icon=author_icon, - branch=branch_name, - action_url=action_url, - ) - - send_parsed_discord_embed(webhook_url, notification, requires_review(data), cicd_id=cicd_id) - -if __name__ == "__main__": - parser = ArgumentParser(description="Send Canvas or Docker notifications to Discord.") - parser.add_argument("--type", required=True, choices=["canvas", "docker"], help="Type of notification") - parser.add_argument("--payload", required=True, help="Path to the payload JSON file") - parser.add_argument("--course-id", required=True, help="Course ID") - parser.add_argument("--author", required=True, help="Name of the author") - parser.add_argument("--author-icon", required=True, help="URL of the author's icon") - parser.add_argument("--branch", required=True, help="Branch name") - parser.add_argument("--action-url", required=True, help="URL to the GHA") - parser.add_argument("--cicd-id", nargs='?', const=None, default=None, help="CI/CD Role ID") - - args = parser.parse_args() - - main(args.type, args.payload, args.course_id, args.author, args.author_icon, args.branch, args.action_url, args.cicd_id) diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/course_updates/create_fallback.py b/notifications/fallback.py similarity index 100% rename from course_updates/create_fallback.py rename to notifications/fallback.py diff --git a/notifications/formatting/__init__.py b/notifications/formatting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py new file mode 100644 index 0000000..b613eb2 --- /dev/null +++ b/notifications/formatting/canvas_format.py @@ -0,0 +1,79 @@ +from datetime import datetime + +from notifications.resources import Notification, Embed, Field, Author, Footer +from notifications.formatting.formatting_utils import spacer, generate_fields, truncate_error, get_course_style, hex_to_int + + +def has_content(data) -> bool: + return bool( + data['deployed_content'] + or data['content_to_review'] + or data['error'] + ) + + +def requires_review(data) -> bool: + return bool(data["content_to_review"] or data["error"]) + + +def format_notification(data, course_id, author, author_icon, branch, action_url) -> Notification: + style = get_course_style("canvas") + + deployed_content = ( + '\n'.join(f'- **{rtype}**: [{content}]({link})' if link else f'- **{rtype}**: {content}' + for rtype, content, link in data['deployed_content'])) \ + if data['deployed_content'] \ + else '*No items deployed*' + + content_to_review = ( + '\n'.join(f'- [{dat[0]}]({dat[1]})' + for dat in data['content_to_review'])) \ + if data['content_to_review'] \ + else '*No items to review*' + + error = data["error"] if data['error'] else '*No errors*' + if error != '*No errors*': + error = truncate_error(error) + + return Notification( + username=style["username"], + avatar_url=style["avatar_url"], + embeds=[Embed( + title=style["title_template"].format(course_id=course_id), + description=f'**`{branch}`**', + color=hex_to_int(style["hex_color"]), + timestamp=datetime.now().isoformat(), + author=Author(name=author, icon_url=author_icon), + footer=Footer( + text=style["footer_text"], + icon_url=style["footer_icon_url"], + ), + fields=[ + spacer(), + *generate_fields( + name='**Deployed Content:**', + value=deployed_content, + inline=False, + ), + spacer(), + *generate_fields( + name='**Content to Review:**', + value=content_to_review, + inline=False, + ), + spacer(), + *generate_fields( + name='**Error:**', + value=error, + inline=False, + ), + spacer(), + Field( + name='**GitHub Action:**', + value=f'[View here]({action_url})', + inline=False, + ), + spacer(), + ], + )], + ) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py new file mode 100644 index 0000000..5054425 --- /dev/null +++ b/notifications/formatting/docker_format.py @@ -0,0 +1,79 @@ +from datetime import datetime + +from notifications.resources import Notification, Embed, Field, Author, Footer +from notifications.formatting.formatting_utils import spacer, generate_fields, truncate_error, get_course_style, hex_to_int + + +def has_content(data) -> bool: + return bool( + data['updated_images'] + or data['failed_images'] + or data['error'] + ) + + +def requires_review(data) -> bool: + return bool(data["failed_images"] or data["error"]) + + +def format_notification(data, course_id, author, author_icon, branch, action_url) -> Notification: + style = get_course_style("docker") + + updated_images = ( + '\n'.join(f'- {image}' + for image in data['updated_images'])) \ + if data['updated_images'] \ + else '*No updated images*' + + failed_images = ( + '\n'.join(f'- {image}' + for image in data['failed_images'])) \ + if data['failed_images'] \ + else '*No items to review*' + + error = data["error"] if data['error'] else '*No errors*' + if error != '*No errors*': + error = truncate_error(error) + + return Notification( + username=style["username"], + avatar_url=style["avatar_url"], + embeds=[Embed( + title=style["title_template"].format(course_id=course_id), + description=f'**`{branch}`**', + color=hex_to_int(style["hex_color"]), + timestamp=datetime.now().isoformat(), + author=Author(name=author, icon_url=author_icon), + footer=Footer( + text=style["footer_text"], + icon_url=style["footer_icon_url"], + ), + fields=[ + spacer(), + *generate_fields( + name='**Updated Image(s):**', + value=updated_images, + inline=True, + ), + spacer(), + *generate_fields( + name='**Failed Image(s):**', + value=failed_images, + inline=True, + ), + spacer(), + *generate_fields( + name='**Error:**', + value=error, + inline=False, + ), + spacer(), + Field( + name='**GitHub Action:**', + value=f'[View here]({action_url})', + inline=False, + ), + spacer(), + ], + )], + ) diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py new file mode 100644 index 0000000..5c25bd8 --- /dev/null +++ b/notifications/formatting/formatting_utils.py @@ -0,0 +1,100 @@ +from notifications.resources import Field +from markdowndata import load +from pathlib import Path + +STYLE_PATH = Path(__file__).parent / "style.md" + + +def hex_to_int(hex_color: str) -> int: + return int(hex_color.lstrip('#'), 16) + + +def _load_styles(): + with open(STYLE_PATH) as f: + return load(f) + + +def get_course_style(ntype: str) -> dict[str, str]: + styles = _load_styles() + for row in styles.get("Course", []): + if row.get("type") == ntype: + return row + return {} + + +def get_pypi_style(ntype: str) -> dict[str, str]: + styles = _load_styles() + for row in styles.get("PyPi Packages", []): + if row.get("type") == ntype: + return row + return {} + + +def spacer(inline=False) -> Field: + return Field(name="\u200b", value="\u200b", inline=inline) + + +def generate_fields(name: str, value: str, inline: bool = False) -> list[Field]: + # Check if this is a code block + if value.startswith('```') and value.endswith('```'): + lines = value.split('\n', 1) + if len(lines) > 1: + rest = lines[1].rsplit('\n```', 1)[0] + + if len(rest) > 1000: + chunks = [] + chunk_size = 1000 + for i in range(0, len(rest), chunk_size): + chunk = rest[i:i + chunk_size] + chunks.append(f"```\n{chunk}\n```") + + return [ + Field( + name=name if i == 0 else f"{name} (continued)", + value=chunk_value, + inline=inline + ) for i, chunk_value in enumerate(chunks) + ] + + # Original logic for non-code-block values + if len(value) > 1024: + chunks = [value[i:i + 1024] for i in range(0, len(value), 1024)] + return [ + Field( + name=name if i == 0 else f"{name} (continued)", + value=chunk, + inline=inline + ) for i, chunk in enumerate(chunks) + ] + else: + return [Field(name=name, value=value, inline=inline)] + + +def truncate_error(error: str, max_chars: int = 900) -> str: + if not error: + return "```\nNo error output available.\n```" + + lines = error.splitlines() + + traceback_indices = [ + i for i, line in enumerate(lines) + if line.strip().startswith("Traceback (most recent call last):") + ] + + if traceback_indices: + tb_start = traceback_indices[-1] + relevant = lines[tb_start:] + + MAX_LINES = 12 + if len(relevant) > MAX_LINES: + relevant = ["... (traceback truncated) ..."] + relevant[-MAX_LINES:] + + message = "\n".join(relevant) + else: + message = "\n".join(lines[-15:]) + + if len(message) > max_chars: + message = message[-max_chars:] + message = "... (truncated) ...\n" + message + + return f"```\n{message}\n```" diff --git a/notifications/formatting/pypi_format.py b/notifications/formatting/pypi_format.py new file mode 100644 index 0000000..adf7c5b --- /dev/null +++ b/notifications/formatting/pypi_format.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from notifications.resources import Notification, Embed, Field, Author, Footer +from notifications.formatting.formatting_utils import spacer, get_pypi_style, hex_to_int + + +def format_notification(ntype, author, author_icon, action_url, success, version) -> Notification: + style = get_pypi_style(ntype) + + if success: + description = f"Updated to version **`{version}`**" + else: + description = f"An **error occurred** while updating {style['display_name']}." + + return Notification( + username=style["username"], + embeds=[Embed( + title=style["title"], + description=description, + color=hex_to_int(style["hex_color"]), + timestamp=datetime.now().isoformat(), + author=Author(name=author, icon_url=author_icon), + footer=Footer( + text=style["footer_text"], + icon_url=style["footer_icon_url"], + ), + fields=[ + spacer(), + Field( + name="GitHub Action:", + value=f"[View Here]({action_url})", + inline=False, + ), + spacer(), + ], + )], + ) diff --git a/notifications/formatting/style.md b/notifications/formatting/style.md new file mode 100644 index 0000000..bdd5d0d --- /dev/null +++ b/notifications/formatting/style.md @@ -0,0 +1,14 @@ +# Course + +| type | username | avatar_url | hex_color | title_template | footer_text | footer_icon_url | +|--------|--------------------------|------------------------------|-----------|-------------------------------------|-------------------------|------------------------------| +| canvas | Canvas Notifications | https://tinyurl.com/ek4ytkan | #F2051D | CS {course_id} - Course Updates | MDXCanvas GitHub Action | https://tinyurl.com/4ky2afzx | +| docker | Gradescope Notifications | https://tinyurl.com/mr2fyjse | #03A1FC | CS {course_id} - Docker Updates | Docker GitHub Action | https://tinyurl.com/4ky2afzx | + +# PyPi Packages + +| type | username | display_name | title | hex_color | footer_text | footer_icon_url | +|------------------|--------------------------------|------------------|-------------------------|-----------|--------------------------------|------------------------------| +| mdxcanvas | MDXCanvas Notifications | MDXCanvas | MDXCanvas Update | #F56236 | MDXCanvas GitHub Action | https://tinyurl.com/4ky2afzx | +| markdowndata | MarkdownData Notifications | MarkdownData | MarkdownData Update | #13DC56 | MarkdownData GitHub Action | https://tinyurl.com/4ky2afzx | +| byu_pytest_utils | BYU Pytest Utils Notifications | BYU Pytest Utils | BYU Pytest Utils Update | #3498DB | BYU Pytest Utils GitHub Action | https://tinyurl.com/4ky2afzx | diff --git a/notifications/resources.py b/notifications/resources.py new file mode 100644 index 0000000..cdf70a9 --- /dev/null +++ b/notifications/resources.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field + + +@dataclass +class Field: + name: str + value: str + inline: bool = False + + +@dataclass +class Author: + name: str + icon_url: str = "" + + +@dataclass +class Footer: + text: str + icon_url: str = "" + + +@dataclass +class Embed: + title: str + description: str + color: int + fields: list[Field] + timestamp: str + author: Author | None = None + footer: Footer | None = None + + +@dataclass +class Notification: + username: str + embeds: list[Embed] + avatar_url: str | None = None + content: str | None = None diff --git a/notifications/send_course.py b/notifications/send_course.py new file mode 100644 index 0000000..92a29e8 --- /dev/null +++ b/notifications/send_course.py @@ -0,0 +1,60 @@ +import json +import os +from argparse import ArgumentParser + +from .formatting import canvas_format, docker_format +from .send_notification import send_notification + + +FORMATTERS = { + "canvas": (canvas_format.format_notification, canvas_format.has_content, canvas_format.requires_review), + "docker": (docker_format.format_notification, docker_format.has_content, docker_format.requires_review), +} + + +def main(ntype, payload, course_id, author, author_icon, branch_name, action_url, cicd_role_id): + webhook_url = os.getenv("DISCORD_WEBHOOK_URL") + if not webhook_url: + raise EnvironmentError("DISCORD_WEBHOOK_URL environment variable is not set.") + + if ntype not in FORMATTERS: + raise ValueError("Invalid notification type. Use 'canvas' or 'docker'.") + + format_notification, has_content, requires_review = FORMATTERS[ntype] + + with open(payload, 'r') as file: + data = json.load(file) + + if not data or not has_content(data): + print("No information to send.") + return + + notification = format_notification( + data=data, + course_id=course_id, + author=author, + author_icon=author_icon, + branch=branch_name, + action_url=action_url, + ) + + if requires_review(data) and cicd_role_id: + notification.content = f"<@&{cicd_role_id}>" + + send_notification(webhook_url, notification) + + +if __name__ == "__main__": + parser = ArgumentParser(description="Send Canvas or Docker notifications to Discord.") + parser.add_argument("--type", required=True, choices=["canvas", "docker"], help="Type of notification") + parser.add_argument("--payload", required=True, help="Path to the payload JSON file") + parser.add_argument("--course-id", required=True, help="Course ID") + parser.add_argument("--author", required=True, help="Name of the author") + parser.add_argument("--author-icon", required=True, help="URL of the author's icon") + parser.add_argument("--branch", required=True, help="Branch name") + parser.add_argument("--action-url", required=True, help="URL to the GHA") + parser.add_argument("--cicd-id", nargs='?', const=None, default=None, help="CI/CD Role ID") + + args = parser.parse_args() + + main(args.type, args.payload, args.course_id, args.author, args.author_icon, args.branch, args.action_url, args.cicd_id) diff --git a/notifications/send_notification.py b/notifications/send_notification.py new file mode 100644 index 0000000..174ee99 --- /dev/null +++ b/notifications/send_notification.py @@ -0,0 +1,155 @@ +from discord_webhook import DiscordWebhook, DiscordEmbed + +from .resources import Embed, Notification + +MAX_EMBED_CHARS = 5900 # 100-char safety margin below Discord's 6000 + + +def _calc_embed_size(embed: Embed) -> int: + """Sum all character-counted fields Discord uses for embed size limits.""" + size = 0 + size += len(embed.title or "") + size += len(embed.description or "") + if embed.author: + size += len(embed.author.name or "") + if embed.footer: + size += len(embed.footer.text or "") + for field in embed.fields: + size += len(field.name or "") + size += len(field.value or "") + return size + + +def _build_chunk(original: Embed, fields: list, is_first: bool, continuation_title: str) -> Embed: + """Construct one embed from the original's metadata + a subset of fields.""" + if is_first: + return Embed( + title=original.title, + description=original.description, + color=original.color, + fields=fields, + timestamp=original.timestamp, + author=original.author, + footer=original.footer, + ) + else: + return Embed( + title=continuation_title, + description="", + color=original.color, + fields=fields, + timestamp=original.timestamp, + author=None, + footer=original.footer, + ) + + +def _chunk_embed(embed: Embed, max_chars: int = MAX_EMBED_CHARS) -> list[Embed]: + """Split an oversized embed into multiple embeds by fields. + + Fields are the unit of splitting — a single field is never split. + Returns [embed] unchanged if already under the limit. + """ + if _calc_embed_size(embed) <= max_chars: + return [embed] + + continuation_title = f"{embed.title} (continued)" + chunks = [] + current_fields = [] + + # Base size for first chunk (title + description + author + footer) + first_base = len(embed.title or "") + len(embed.description or "") + if embed.author: + first_base += len(embed.author.name or "") + if embed.footer: + first_base += len(embed.footer.text or "") + + # Base size for continuation chunks (continuation title + footer) + cont_base = len(continuation_title) + if embed.footer: + cont_base += len(embed.footer.text or "") + + is_first = True + current_size = first_base + + for field in embed.fields: + field_size = len(field.name or "") + len(field.value or "") + + if current_fields and (current_size + field_size) > max_chars: + chunks.append(_build_chunk(embed, current_fields, is_first, continuation_title)) + is_first = False + current_fields = [] + current_size = cont_base + + current_fields.append(field) + current_size += field_size + + if current_fields: + chunks.append(_build_chunk(embed, current_fields, is_first, continuation_title)) + + return chunks + + +def send_notification(webhook_url: str, notification: Notification): + # Chunk all embeds + all_chunks = [] + for embed_data in notification.embeds: + all_chunks.extend(_chunk_embed(embed_data)) + + for i, embed_data in enumerate(all_chunks): + is_first = (i == 0) + + webhook = DiscordWebhook( + url=webhook_url, + username=notification.username, + avatar_url=notification.avatar_url, + content=notification.content if is_first else None, + ) + + embed = DiscordEmbed( + title=embed_data.title, + description=embed_data.description, + color=embed_data.color, + timestamp=embed_data.timestamp, + ) + + if embed_data.author: + embed.set_author( + name=embed_data.author.name, + icon_url=embed_data.author.icon_url, + ) + + if embed_data.footer: + embed.set_footer( + text=embed_data.footer.text, + icon_url=embed_data.footer.icon_url, + ) + + for field in embed_data.fields: + field_name = field.name + field_value = field.value + + if not field_name or not field_name.strip(): + field_name = "\u200b" + if not field_value or not field_value.strip(): + field_value = "\u200b" + + embed.add_embed_field( + name=field_name, + value=field_value, + inline=field.inline, + ) + + webhook.add_embed(embed) + + try: + response = webhook.execute() + except Exception as e: + print(f"\u274c Error sending chunk {i + 1}/{len(all_chunks)}: {e}") + continue + + # TODO: Check for Discord's specific character-limit error code + if response.status_code >= 400: + print(f"\u274c Discord returned status {response.status_code} on chunk {i + 1}/{len(all_chunks)}: {response.text}") + else: + print(f"\u2705 Sent chunk {i + 1}/{len(all_chunks)} successfully.") diff --git a/notifications/send_pypi.py b/notifications/send_pypi.py new file mode 100644 index 0000000..98b5e60 --- /dev/null +++ b/notifications/send_pypi.py @@ -0,0 +1,41 @@ +import os +from argparse import ArgumentParser + +from .formatting.pypi_format import format_notification +from .send_notification import send_notification + + +def main(ntype, author, author_icon, action_url, success=None, version=None, cicd_role_id=None): + webhook_url = os.getenv("DISCORD_WEBHOOK_URL") + if not webhook_url: + raise EnvironmentError("DISCORD_WEBHOOK_URL environment variable is not set.") + + notification = format_notification( + ntype=ntype, + author=author, + author_icon=author_icon, + action_url=action_url, + success=success, + version=version, + ) + + if not success and cicd_role_id: + notification.content = f"<@&{cicd_role_id}>" + + send_notification(webhook_url, notification) + + +if __name__ == "__main__": + parser = ArgumentParser(description="Send PyPI update notifications to Discord.") + parser.add_argument("--type", required=True, choices=["mdxcanvas", "markdowndata", "byu_pytest_utils"], + help="Type of notification") + parser.add_argument("--author", required=True, help="Name of the author") + parser.add_argument("--author-icon", required=True, help="URL of the author's icon") + parser.add_argument("--action-url", required=True, help="URL to the GHA") + parser.add_argument("--success", nargs='?', const=None, default=None, help="Bool indicating success or failure") + parser.add_argument("--version", nargs='?', const=None, default=None, help="PyPi version") + parser.add_argument("--cicd-id", nargs='?', const=None, default=None, help="CI/CD Role ID") + + args = parser.parse_args() + + main(args.type, args.author, args.author_icon, args.action_url, args.success, args.version, args.cicd_id) diff --git a/pypi_updates/build_send_update_notification_docker.sh b/pypi_updates/build_send_update_notification_docker.sh deleted file mode 100644 index b57f4a6..0000000 --- a/pypi_updates/build_send_update_notification_docker.sh +++ /dev/null @@ -1,21 +0,0 @@ -# Build the send notification script image - -IMAGE_NAME="byucscourseops/send_update_notification" -IMAGE_TAG="latest" - -docker buildx build \ - --platform linux/amd64,linux/arm64 \ - -t ${IMAGE_NAME}:${IMAGE_TAG} \ - --push \ - -f - . < Field: - return Field(name="", value="\u200b", inline=inline) - - -def main(ntype, author, author_icon, action_url, success=None, version=None, cicd_id=None): - webhook_url = os.getenv("DISCORD_WEBHOOK_URL") - if not webhook_url: - raise EnvironmentError("DISCORD_WEBHOOK_URL environment variable is not set.") - - type_vars = { - "mdxcanvas": { - "username": "MDXCanvas Notifications", - "title": "MDXCanvas Update", - "success_description": lambda x: f"Updated to version **`{x}`**", - "failure_description": "An **error occurred** while updating MDXCanvas.", - "color": 16081462, - "footer_text": "MDXCanvas GitHub Action", - "footer_icon_url": "https://tinyurl.com/4ky2afzx" - }, - "markdowndata": { - "username": "MarkdownData Notifications", - "title": "MarkdownData Update", - "success_description": lambda x: f"Updated to version **`{x}`**", - "failure_description": "An **error occurred** while updating MarkdownData.", - "color": 1301590, - "footer_text": "MarkdownData GitHub Action", - "footer_icon_url": "https://tinyurl.com/4ky2afzx" - }, - "byu_pytest_utils": { - "username": "BYU Pytest Utils Notifications", - "title": "BYU Pytest Utils Update", - "success_description": lambda x: f"Updated to version **`{x}`**", - "failure_description": "An **error occurred** while updating BYU Pytest Utils.", - "color": 3447003, - "footer_text": "BYU Pytest Utils GitHub Action", - "footer_icon_url": "https://tinyurl.com/4dyna5du" - } - } - - if success: - description = type_vars[ntype]["success_description"](version) - else: - description = type_vars[ntype]["failure_description"] - - webhook = DiscordWebhook( - url=webhook_url, - username=type_vars[ntype]["username"], - avatar_url=BEAN_LAB_LOGO, - content=f"<@&{cicd_id}>" if not success and cicd_id else None, - ) - - embed = DiscordEmbed( - title=type_vars[ntype]["title"], - description=description, - color=type_vars[ntype]["color"], - timestamp=datetime.now().isoformat() - ) - - embed.set_author( - name=author, - icon_url=author_icon, - ) - - embed.add_embed_field( - name="\u200b", - value="\u200b", - inline=False - ) - - embed.add_embed_field( - name="GitHub Action:", - value=f"[View Here]({action_url})", - inline=False - ) - - embed.add_embed_field( - name="\u200b", - value="\u200b", - inline=False - ) - - embed.set_footer( - text=type_vars[ntype]["footer_text"], - icon_url=type_vars[ntype]["footer_icon_url"] - ) - - webhook.add_embed(embed) - - response = webhook.execute() - if response.status_code >= 400: - print(f"❌ Discord returned status {response.status_code}: {response.text}") - else: - print("✅ Sent message successfully.") - - -if __name__ == "__main__": - parser = ArgumentParser(description="Send Canvas or Docker notifications to Discord.") - parser.add_argument("--type", required=True, choices=["mdxcanvas", "markdowndata", "byu_pytest_utils"], - help="Type of notification") - parser.add_argument("--author", required=True, help="Name of the author") - parser.add_argument("--author-icon", required=True, help="URL of the author's icon") - parser.add_argument("--action-url", required=True, help="URL to the GHA") - parser.add_argument("--success", nargs='?', const=None, default=None, help="Bool indicating success or failure") - parser.add_argument("--version", nargs='?', const=None, default=None, help="PyPi version") - parser.add_argument("--cicd-id", nargs='?', const=None, default=None, help="CI/CD Role ID") - - args = parser.parse_args() - - main(args.type, args.author, args.author_icon, args.action_url, args.success, args.version, args.cicd_id) diff --git a/tests/test_chunking.py b/tests/test_chunking.py new file mode 100644 index 0000000..63354e1 --- /dev/null +++ b/tests/test_chunking.py @@ -0,0 +1,206 @@ +import pytest + +from notifications.resources import Embed, Field, Author, Footer +from notifications.send_notification import _calc_embed_size, _chunk_embed, MAX_EMBED_CHARS + + +class TestCalcEmbedSize: + def test_basic_size(self): + embed = Embed( + title="Hello", + description="World", + color=0xFF0000, + fields=[Field(name="key", value="val")], + timestamp="2025-01-01T00:00:00Z", + ) + assert _calc_embed_size(embed) == len("Hello") + len("World") + len("key") + len("val") + + def test_includes_footer_and_author(self): + embed = Embed( + title="T", + description="D", + color=0, + fields=[], + timestamp="", + author=Author(name="AuthorName"), + footer=Footer(text="FooterText"), + ) + assert _calc_embed_size(embed) == len("T") + len("D") + len("AuthorName") + len("FooterText") + + def test_empty_embed(self): + embed = Embed( + title="", + description="", + color=0, + fields=[], + timestamp="", + ) + assert _calc_embed_size(embed) == 0 + + def test_spacer_fields(self): + embed = Embed( + title="", + description="", + color=0, + fields=[Field(name="\u200b", value="\u200b")], + timestamp="", + ) + # Zero-width spaces are 1 char each + assert _calc_embed_size(embed) == 2 + + +class TestChunkEmbed: + def test_small_embed_passthrough(self): + embed = Embed( + title="Small", + description="Desc", + color=0x00FF00, + fields=[Field(name="f1", value="v1")], + timestamp="2025-01-01T00:00:00Z", + author=Author(name="Bot"), + footer=Footer(text="Footer"), + ) + chunks = _chunk_embed(embed) + assert len(chunks) == 1 + assert chunks[0] is embed + + def test_large_embed_splits(self): + # Create fields that will exceed the limit + fields = [Field(name=f"field-{i}", value="x" * 500) for i in range(20)] + embed = Embed( + title="Big Embed", + description="Description", + color=0xFF0000, + fields=fields, + timestamp="2025-01-01T00:00:00Z", + author=Author(name="Bot"), + footer=Footer(text="Footer"), + ) + chunks = _chunk_embed(embed) + assert len(chunks) > 1 + + def test_first_chunk_has_original_metadata(self): + fields = [Field(name=f"f{i}", value="x" * 500) for i in range(20)] + embed = Embed( + title="Original Title", + description="Original Desc", + color=0xABCDEF, + fields=fields, + timestamp="2025-06-01T00:00:00Z", + author=Author(name="TestBot"), + footer=Footer(text="TestFooter"), + ) + chunks = _chunk_embed(embed) + first = chunks[0] + assert first.title == "Original Title" + assert first.description == "Original Desc" + assert first.author is not None + assert first.author.name == "TestBot" + assert first.color == 0xABCDEF + + def test_continuation_chunks_have_continued_title_and_no_author(self): + fields = [Field(name=f"f{i}", value="x" * 500) for i in range(20)] + embed = Embed( + title="My Title", + description="Desc", + color=0x123456, + fields=fields, + timestamp="2025-01-01T00:00:00Z", + author=Author(name="Bot"), + footer=Footer(text="Footer"), + ) + chunks = _chunk_embed(embed) + for chunk in chunks[1:]: + assert chunk.title == "My Title (continued)" + assert chunk.author is None + assert chunk.description == "" + + def test_all_chunks_same_color(self): + fields = [Field(name=f"f{i}", value="x" * 500) for i in range(20)] + embed = Embed( + title="Title", + description="Desc", + color=0xDEADBE, + fields=fields, + timestamp="", + ) + chunks = _chunk_embed(embed) + for chunk in chunks: + assert chunk.color == 0xDEADBE + + def test_all_chunks_have_footer(self): + fields = [Field(name=f"f{i}", value="x" * 500) for i in range(20)] + embed = Embed( + title="Title", + description="Desc", + color=0, + fields=fields, + timestamp="", + footer=Footer(text="BeanLab"), + ) + chunks = _chunk_embed(embed) + for chunk in chunks: + assert chunk.footer is not None + assert chunk.footer.text == "BeanLab" + + def test_each_chunk_under_limit(self): + fields = [Field(name=f"field-{i}", value="x" * 500) for i in range(20)] + embed = Embed( + title="Title", + description="Desc", + color=0, + fields=fields, + timestamp="", + author=Author(name="Bot"), + footer=Footer(text="Footer"), + ) + chunks = _chunk_embed(embed) + for chunk in chunks: + assert _calc_embed_size(chunk) <= MAX_EMBED_CHARS + + def test_total_field_count_preserved(self): + fields = [Field(name=f"f{i}", value="x" * 500) for i in range(15)] + embed = Embed( + title="Title", + description="Desc", + color=0, + fields=fields, + timestamp="", + ) + chunks = _chunk_embed(embed) + total_fields = sum(len(c.fields) for c in chunks) + assert total_fields == 15 + + def test_no_footer_case(self): + fields = [Field(name=f"f{i}", value="x" * 500) for i in range(20)] + embed = Embed( + title="Title", + description="Desc", + color=0, + fields=fields, + timestamp="", + footer=None, + ) + chunks = _chunk_embed(embed) + assert len(chunks) > 1 + for chunk in chunks: + assert chunk.footer is None + + def test_typical_pypi_embed_stays_single(self): + # A typical pypi notification is small enough to fit in one embed + fields = [ + Field(name="Package", value="my-package"), + Field(name="Version", value="1.2.3"), + Field(name="Status", value="Published"), + ] + embed = Embed( + title="PyPI Update", + description="A new version has been published.", + color=0x3B82F6, + fields=fields, + timestamp="2025-01-01T00:00:00Z", + author=Author(name="PyPI Bot"), + footer=Footer(text="BeanLab Dev Utils"), + ) + chunks = _chunk_embed(embed) + assert len(chunks) == 1 From 27aa6c6ca7c17362279dadebda452fee4f4434c1 Mon Sep 17 00:00:00 2001 From: robbykap Date: Fri, 13 Feb 2026 12:40:03 -0700 Subject: [PATCH 02/32] Fix action paths --- .../actions/setup-notifications/action.yml | 21 ------------------- .github/workflows/docker_automation.yaml | 11 ++++++++-- .github/workflows/mdxcanvas_automation.yaml | 15 ++++++++----- .github/workflows/poetry_publish.yaml | 13 +++++++++--- 4 files changed, 29 insertions(+), 31 deletions(-) delete mode 100644 .github/actions/setup-notifications/action.yml diff --git a/.github/actions/setup-notifications/action.yml b/.github/actions/setup-notifications/action.yml deleted file mode 100644 index 5dc3137..0000000 --- a/.github/actions/setup-notifications/action.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Setup Notifications -description: Checkout utils repo and install discord-webhook plus any extra packages - -inputs: - extra-packages: - description: "Additional pip packages to install (e.g. mdxcanvas==0.3.27)" - required: false - default: "" - -runs: - using: composite - steps: - - name: Checkout utils repo - uses: actions/checkout@v4 - with: - repository: BYU-CS-Course-Ops/utils - path: utils - - - name: Install notification dependencies - shell: bash - run: pip install discord-webhook ${{ inputs.extra-packages }} diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index e4ec75e..b87e520 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -31,8 +31,15 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Setup notifications - uses: ./.github/actions/setup-notifications + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils + path: utils + + - name: Install notification dependencies + shell: bash + run: pip install discord-webhook ${{ inputs.extra-packages }} - name: Get changed files run: | diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index 9465fd1..31da831 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -43,16 +43,21 @@ jobs: OUTPUT_PATH: ${{ github.workspace }}/.github/logs/mdxcanvas_output.json steps: - - name: Checkout repos + - name: Checkout repo uses: actions/checkout@v4 with: fetch-depth: 0 submodules: recursive - - name: Setup notifications - uses: ./.github/actions/setup-notifications + - name: Checkout utils repo + uses: actions/checkout@v4 with: - extra-packages: mdxcanvas==${{ inputs.mdxcanvas_version }} + repository: BYU-CS-Course-Ops/utils + path: utils + + - name: Install notification dependencies + shell: bash + run: pip install discord-webhook ${{ inputs.extra-packages }} - name: Create logs directory run: mkdir -p "$LOGS_DIR" @@ -86,7 +91,7 @@ jobs: cat "$LOGS_DIR/mdxcanvas.stderr.log" - name: Send course notification - uses: ./.github/actions/send-course-notification + uses: utils/.github/actions/send-course-notification with: notification-type: canvas output-type: MDXCanvas diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 7108495..e19067c 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -68,11 +68,18 @@ jobs: runs-on: ubuntu-latest if: always() steps: - - name: Setup notifications - uses: ./.github/actions/setup-notifications + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils + path: utils + + - name: Install notification dependencies + shell: bash + run: pip install discord-webhook ${{ inputs.extra-packages }} - name: Send PyPI notification - uses: ./.github/actions/send-pypi-notification + uses: utils/.github/actions/send-pypi-notification with: pypi-package: ${{ inputs.pypi_package }} success: ${{ needs.poetry-publish.outputs.success }} From 963d88bb25216f7598f74479614c4fd83d7bcd87 Mon Sep 17 00:00:00 2001 From: robbykap Date: Fri, 13 Feb 2026 12:47:00 -0700 Subject: [PATCH 03/32] Update mdxcanvas_automation.yaml --- .github/workflows/mdxcanvas_automation.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index 31da831..f52707e 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -52,7 +52,7 @@ jobs: - name: Checkout utils repo uses: actions/checkout@v4 with: - repository: BYU-CS-Course-Ops/utils + repository: BYU-CS-Course-Ops/utils@canvas-notification-updates path: utils - name: Install notification dependencies From c61eb595d1dd7f0c59bd2379f9b7ac408a9aa70c Mon Sep 17 00:00:00 2001 From: robbykap Date: Fri, 13 Feb 2026 12:50:33 -0700 Subject: [PATCH 04/32] Fix paths --- .github/workflows/check_toml.yaml | 8 +------- .github/workflows/docker_automation.yaml | 8 +------- .github/workflows/mdxcanvas_automation.yaml | 8 +------- .github/workflows/poetry_publish.yaml | 8 +------- 4 files changed, 4 insertions(+), 28 deletions(-) diff --git a/.github/workflows/check_toml.yaml b/.github/workflows/check_toml.yaml index e284d3c..2caad83 100644 --- a/.github/workflows/check_toml.yaml +++ b/.github/workflows/check_toml.yaml @@ -27,12 +27,6 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Checkout utils repo - uses: actions/checkout@v4 - with: - repository: BYU-CS-Course-Ops/utils - path: utils - - name: Get version from PyPI id: get_pypi_version run: | @@ -57,5 +51,5 @@ jobs: - name: Check for version update id: extract_update run: | - python3 utils/.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}" + python3 ./.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}" echo "uped_toml=true" >> $GITHUB_OUTPUT diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index b87e520..89e72db 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -31,12 +31,6 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Checkout utils repo - uses: actions/checkout@v4 - with: - repository: BYU-CS-Course-Ops/utils - path: utils - - name: Install notification dependencies shell: bash run: pip install discord-webhook ${{ inputs.extra-packages }} @@ -70,7 +64,7 @@ jobs: STDOUT_LOG="${{ github.workspace }}/.github/logs/docker_stdout.log" STDERR_LOG="${{ github.workspace }}/.github/logs/docker_stderr.log" - python utils/docker_updates/build_dockers.py \ + python ./docker_updates/build_dockers.py \ --files "${{ env.FILES }}" \ --output-file "${{ github.workspace }}/.github/logs/docker_output.json" \ --root-dir "${{ github.workspace }}" \ diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index f52707e..bdfc49c 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -49,12 +49,6 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Checkout utils repo - uses: actions/checkout@v4 - with: - repository: BYU-CS-Course-Ops/utils@canvas-notification-updates - path: utils - - name: Install notification dependencies shell: bash run: pip install discord-webhook ${{ inputs.extra-packages }} @@ -91,7 +85,7 @@ jobs: cat "$LOGS_DIR/mdxcanvas.stderr.log" - name: Send course notification - uses: utils/.github/actions/send-course-notification + uses: ./.github/actions/send-course-notification with: notification-type: canvas output-type: MDXCanvas diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index e19067c..89db223 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -68,18 +68,12 @@ jobs: runs-on: ubuntu-latest if: always() steps: - - name: Checkout utils repo - uses: actions/checkout@v4 - with: - repository: BYU-CS-Course-Ops/utils - path: utils - - name: Install notification dependencies shell: bash run: pip install discord-webhook ${{ inputs.extra-packages }} - name: Send PyPI notification - uses: utils/.github/actions/send-pypi-notification + uses: ./.github/actions/send-pypi-notification with: pypi-package: ${{ inputs.pypi_package }} success: ${{ needs.poetry-publish.outputs.success }} From 9ad268b3ccfd3e11be8c6839ac5305c24483b9d0 Mon Sep 17 00:00:00 2001 From: robbykap Date: Fri, 13 Feb 2026 12:51:53 -0700 Subject: [PATCH 05/32] Revert "Fix paths" This reverts commit c61eb595d1dd7f0c59bd2379f9b7ac408a9aa70c. --- .github/workflows/check_toml.yaml | 8 +++++++- .github/workflows/docker_automation.yaml | 8 +++++++- .github/workflows/mdxcanvas_automation.yaml | 8 +++++++- .github/workflows/poetry_publish.yaml | 8 +++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check_toml.yaml b/.github/workflows/check_toml.yaml index 2caad83..e284d3c 100644 --- a/.github/workflows/check_toml.yaml +++ b/.github/workflows/check_toml.yaml @@ -27,6 +27,12 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils + path: utils + - name: Get version from PyPI id: get_pypi_version run: | @@ -51,5 +57,5 @@ jobs: - name: Check for version update id: extract_update run: | - python3 ./.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}" + python3 utils/.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}" echo "uped_toml=true" >> $GITHUB_OUTPUT diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index 89e72db..b87e520 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -31,6 +31,12 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils + path: utils + - name: Install notification dependencies shell: bash run: pip install discord-webhook ${{ inputs.extra-packages }} @@ -64,7 +70,7 @@ jobs: STDOUT_LOG="${{ github.workspace }}/.github/logs/docker_stdout.log" STDERR_LOG="${{ github.workspace }}/.github/logs/docker_stderr.log" - python ./docker_updates/build_dockers.py \ + python utils/docker_updates/build_dockers.py \ --files "${{ env.FILES }}" \ --output-file "${{ github.workspace }}/.github/logs/docker_output.json" \ --root-dir "${{ github.workspace }}" \ diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index bdfc49c..f52707e 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -49,6 +49,12 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils@canvas-notification-updates + path: utils + - name: Install notification dependencies shell: bash run: pip install discord-webhook ${{ inputs.extra-packages }} @@ -85,7 +91,7 @@ jobs: cat "$LOGS_DIR/mdxcanvas.stderr.log" - name: Send course notification - uses: ./.github/actions/send-course-notification + uses: utils/.github/actions/send-course-notification with: notification-type: canvas output-type: MDXCanvas diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 89db223..e19067c 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -68,12 +68,18 @@ jobs: runs-on: ubuntu-latest if: always() steps: + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils + path: utils + - name: Install notification dependencies shell: bash run: pip install discord-webhook ${{ inputs.extra-packages }} - name: Send PyPI notification - uses: ./.github/actions/send-pypi-notification + uses: utils/.github/actions/send-pypi-notification with: pypi-package: ${{ inputs.pypi_package }} success: ${{ needs.poetry-publish.outputs.success }} From 71fea045f155b84ab989033baca9f4a9dea84fb7 Mon Sep 17 00:00:00 2001 From: robbykap Date: Fri, 13 Feb 2026 12:53:30 -0700 Subject: [PATCH 06/32] Update mdxcanvas_automation.yaml --- .github/workflows/mdxcanvas_automation.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index f52707e..31da831 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -52,7 +52,7 @@ jobs: - name: Checkout utils repo uses: actions/checkout@v4 with: - repository: BYU-CS-Course-Ops/utils@canvas-notification-updates + repository: BYU-CS-Course-Ops/utils path: utils - name: Install notification dependencies From 9c33d8ef70d6a9346b269022ac66d35a876f291f Mon Sep 17 00:00:00 2001 From: robbykap Date: Fri, 13 Feb 2026 12:57:13 -0700 Subject: [PATCH 07/32] Update mdxcanvas_automation.yaml --- .github/workflows/mdxcanvas_automation.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index 31da831..0b6aed3 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -91,7 +91,7 @@ jobs: cat "$LOGS_DIR/mdxcanvas.stderr.log" - name: Send course notification - uses: utils/.github/actions/send-course-notification + uses: ./utils/.github/actions/send-course-notification with: notification-type: canvas output-type: MDXCanvas From fee6ec8e9b98b42f4f4693017c22e86d047bff82 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 4 Mar 2026 15:29:56 -0700 Subject: [PATCH 08/32] Update mdxcanvas_automation.yaml --- .github/workflows/mdxcanvas_automation.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index 0b6aed3..b055994 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -91,7 +91,7 @@ jobs: cat "$LOGS_DIR/mdxcanvas.stderr.log" - name: Send course notification - uses: ./utils/.github/actions/send-course-notification + uses: ./.github/actions/send-course-notification with: notification-type: canvas output-type: MDXCanvas From 3e2fb3352aa0012b17a91b55327be4d584f7cb57 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 4 Mar 2026 15:40:57 -0700 Subject: [PATCH 09/32] Update mdxcanvas_automation.yaml --- .github/workflows/mdxcanvas_automation.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index b055994..504f7b7 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -53,6 +53,7 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils + ref: canvas-notification-updates path: utils - name: Install notification dependencies @@ -91,7 +92,7 @@ jobs: cat "$LOGS_DIR/mdxcanvas.stderr.log" - name: Send course notification - uses: ./.github/actions/send-course-notification + uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@canvas-notification-updates with: notification-type: canvas output-type: MDXCanvas From 0009f217e76f869a7c1758d725d308984185a725 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 4 Mar 2026 15:45:54 -0700 Subject: [PATCH 10/32] Update mdxcanvas_automation.yaml --- .github/workflows/mdxcanvas_automation.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index 504f7b7..9917b31 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -56,6 +56,9 @@ jobs: ref: canvas-notification-updates path: utils + - name: Install MDXCanvas + run: pip install mdxcanvas==${{ inputs.mdxcanvas_version }} + - name: Install notification dependencies shell: bash run: pip install discord-webhook ${{ inputs.extra-packages }} From 191a83a57781284d3753ffd982b46dc4bdcf898a Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 4 Mar 2026 15:54:40 -0700 Subject: [PATCH 11/32] Update formatting_utils.py --- notifications/formatting/formatting_utils.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index 5c25bd8..635ad4c 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -58,7 +58,22 @@ def generate_fields(name: str, value: str, inline: bool = False) -> list[Field]: # Original logic for non-code-block values if len(value) > 1024: - chunks = [value[i:i + 1024] for i in range(0, len(value), 1024)] + lines = value.split('\n') + chunks = [] + current = [] + current_len = 0 + for line in lines: + # +1 for the newline we'll rejoin with + needed = len(line) + (1 if current else 0) + if current and current_len + needed > 1024: + chunks.append('\n'.join(current)) + current = [line] + current_len = len(line) + else: + current.append(line) + current_len += needed + if current: + chunks.append('\n'.join(current)) return [ Field( name=name if i == 0 else f"{name} (continued)", From 3e3834b430f2031c7a120e665a6f539672271b7a Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 4 Mar 2026 16:14:32 -0700 Subject: [PATCH 12/32] Update formatting_utils.py --- notifications/formatting/formatting_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index 635ad4c..b4c8ea0 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -50,7 +50,7 @@ def generate_fields(name: str, value: str, inline: bool = False) -> list[Field]: return [ Field( - name=name if i == 0 else f"{name} (continued)", + name=name if i == 0 else "…", value=chunk_value, inline=inline ) for i, chunk_value in enumerate(chunks) @@ -76,7 +76,7 @@ def generate_fields(name: str, value: str, inline: bool = False) -> list[Field]: chunks.append('\n'.join(current)) return [ Field( - name=name if i == 0 else f"{name} (continued)", + name=name if i == 0 else "…", value=chunk, inline=inline ) for i, chunk in enumerate(chunks) From 7d94cf46007d3705b9a93c902fe81cf288bdcff5 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 4 Mar 2026 16:15:29 -0700 Subject: [PATCH 13/32] Update formatting_utils.py --- notifications/formatting/formatting_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index b4c8ea0..4c35ab3 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -50,7 +50,7 @@ def generate_fields(name: str, value: str, inline: bool = False) -> list[Field]: return [ Field( - name=name if i == 0 else "…", + name=name if i == 0 else "\u200b", value=chunk_value, inline=inline ) for i, chunk_value in enumerate(chunks) @@ -76,7 +76,7 @@ def generate_fields(name: str, value: str, inline: bool = False) -> list[Field]: chunks.append('\n'.join(current)) return [ Field( - name=name if i == 0 else "…", + name=name if i == 0 else "\u200b", value=chunk, inline=inline ) for i, chunk in enumerate(chunks) From 8be61ad8ef11e6e3dad93a71307f443a9a294666 Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 14:52:23 -0600 Subject: [PATCH 14/32] Redesign Discord notification infrastructure - Canvas: Add course name/link, resource type summary counts, move Content to Review above Deployed Content for skimmability - Docker: Add course name/link, summary counts, move Failed Images above Updated Images - PyPi: Add PyPI package link, version transition display (old -> new), migrate to OIDC trusted publishers (pypa/gh-action-pypi-publish) - Fix check_toml.yaml to explicitly output uped_toml=false on failure - Fix docker_automation.yaml uses: path to full repo reference - Fix github.ref -> github.ref_name for clean branch display - Quote all CLI args in send-pypi-notification action - Expose pypi_version output from check_toml workflow - Add shared helpers: build_resource_summary, resource_count_fields, course_info_field - Add pypi_name column to style.md for URL generation Co-Authored-By: Claude Opus 4.6 --- .../send-course-notification/action.yml | 59 ++++++++++-- .../actions/send-pypi-notification/action.yml | 20 ++-- .github/workflows/check_toml.yaml | 12 ++- .github/workflows/docker_automation.yaml | 14 ++- .github/workflows/mdxcanvas_automation.yaml | 4 +- .github/workflows/poetry_publish.yaml | 45 +++++---- notifications/formatting/canvas_format.py | 94 ++++++++++++------- notifications/formatting/docker_format.py | 94 ++++++++++++------- notifications/formatting/formatting_utils.py | 30 ++++++ notifications/formatting/pypi_format.py | 37 +++++--- notifications/formatting/style.md | 10 +- notifications/send_course.py | 10 +- notifications/send_pypi.py | 8 +- 13 files changed, 311 insertions(+), 126 deletions(-) diff --git a/.github/actions/send-course-notification/action.yml b/.github/actions/send-course-notification/action.yml index 219c66a..2147eb0 100644 --- a/.github/actions/send-course-notification/action.yml +++ b/.github/actions/send-course-notification/action.yml @@ -26,6 +26,14 @@ inputs: discord-webhook-url: description: "Discord webhook URL" required: true + course-info-path: + description: "Path to course_info.json (optional, for course name/URL extraction)" + required: false + default: "" + global-args-path: + description: "Path to global_args.yaml (optional, for course name extraction)" + required: false + default: "" runs: using: composite @@ -47,17 +55,50 @@ runs: AVATAR_URL=$(curl -s "https://api.github.com/users/${{ github.actor }}" | jq -r '.avatar_url') echo "url=$AVATAR_URL" >> $GITHUB_OUTPUT + - name: Extract course metadata + id: course-meta + shell: bash + run: | + COURSE_NAME="" + COURSE_URL="" + if [ -n "${{ inputs.course-info-path }}" ] && [ -f "${{ inputs.course-info-path }}" ]; then + API_URL=$(jq -r '.CANVAS_API_URL' "${{ inputs.course-info-path }}") + COURSE_ID=$(jq -r '.CANVAS_COURSE_ID' "${{ inputs.course-info-path }}") + if [ "$API_URL" != "null" ] && [ "$COURSE_ID" != "null" ]; then + COURSE_URL="${API_URL}/courses/${COURSE_ID}" + fi + fi + if [ -n "${{ inputs.global-args-path }}" ] && [ -f "${{ inputs.global-args-path }}" ]; then + COURSE_NAME=$(python3 -c " + import yaml + with open('${{ inputs.global-args-path }}') as f: + data = yaml.safe_load(f) + print(data.get('course_name', '')) + " 2>/dev/null || echo "") + fi + echo "name=$COURSE_NAME" >> $GITHUB_OUTPUT + echo "url=$COURSE_URL" >> $GITHUB_OUTPUT + - name: Send course notification to Discord shell: bash env: DISCORD_WEBHOOK_URL: ${{ inputs.discord-webhook-url }} run: | - PYTHONPATH=utils python -m notifications.send_course \ - --type "${{ inputs.notification-type }}" \ - --payload "${{ inputs.payload-path }}" \ - --course-id "${{ inputs.course-id }}" \ - --author "${{ github.actor }}" \ - --author-icon "${{ steps.avatar.outputs.url }}" \ - --branch "${{ github.ref }}" \ - --cicd-id "${{ inputs.discord-role }}" \ - --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + CMD="PYTHONPATH=utils python -m notifications.send_course \ + --type '${{ inputs.notification-type }}' \ + --payload '${{ inputs.payload-path }}' \ + --course-id '${{ inputs.course-id }}' \ + --author '${{ github.actor }}' \ + --author-icon '${{ steps.avatar.outputs.url }}' \ + --branch '${{ github.ref_name }}' \ + --cicd-id '${{ inputs.discord-role }}' \ + --action-url 'https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}'" + + if [ -n "${{ steps.course-meta.outputs.name }}" ]; then + CMD="$CMD --course-name '${{ steps.course-meta.outputs.name }}'" + fi + if [ -n "${{ steps.course-meta.outputs.url }}" ]; then + CMD="$CMD --course-url '${{ steps.course-meta.outputs.url }}'" + fi + + eval "$CMD" diff --git a/.github/actions/send-pypi-notification/action.yml b/.github/actions/send-pypi-notification/action.yml index 40be3a1..bf02d0c 100644 --- a/.github/actions/send-pypi-notification/action.yml +++ b/.github/actions/send-pypi-notification/action.yml @@ -20,6 +20,10 @@ inputs: discord-webhook-url: description: "Discord webhook URL" required: true + old-version: + description: "Previous PyPI version (for version transition display)" + required: false + default: "" runs: using: composite @@ -37,18 +41,22 @@ runs: DISCORD_WEBHOOK_URL: ${{ inputs.discord-webhook-url }} run: | CMD="PYTHONPATH=utils python -m notifications.send_pypi \ - --type ${{ inputs.pypi-package }} \ - --author ${{ github.actor }} \ - --author-icon ${{ steps.avatar.outputs.url }} \ - --action-url https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + --type '${{ inputs.pypi-package }}' \ + --author '${{ github.actor }}' \ + --author-icon '${{ steps.avatar.outputs.url }}' \ + --action-url 'https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}'" if [ '${{ inputs.success }}' == 'true' ] && \ [ '${{ inputs.toml-updated }}' == 'true' ]; then CMD="$CMD --success 1" fi - CMD="$CMD --version ${{ inputs.version }} \ - --cicd-id ${{ inputs.discord-role }}" + CMD="$CMD --version '${{ inputs.version }}' \ + --cicd-id '${{ inputs.discord-role }}'" + + if [ -n "${{ inputs.old-version }}" ]; then + CMD="$CMD --old-version '${{ inputs.old-version }}'" + fi echo "$CMD" eval "$CMD" diff --git a/.github/workflows/check_toml.yaml b/.github/workflows/check_toml.yaml index e284d3c..c62892f 100644 --- a/.github/workflows/check_toml.yaml +++ b/.github/workflows/check_toml.yaml @@ -13,6 +13,9 @@ on: version: description: "The version from pyproject.toml" value: ${{ jobs.check.outputs.version }} + pypi_version: + description: "The current version on PyPI" + value: ${{ jobs.check.outputs.pypi_version }} jobs: check: @@ -20,6 +23,7 @@ jobs: outputs: uped_toml: ${{ steps.extract_update.outputs.uped_toml }} version: ${{ steps.get_build_version.outputs.version }} + pypi_version: ${{ steps.get_pypi_version.outputs.pypi_version }} steps: - name: Checkout repo uses: actions/checkout@v4 @@ -38,6 +42,7 @@ jobs: run: | VERSION=$(curl -s https://pypi.org/pypi/${{ inputs.pypi_package }}/json | jq -r .info.version) echo "pypi_version=$VERSION" >> $GITHUB_ENV + echo "pypi_version=$VERSION" >> $GITHUB_OUTPUT - name: Install Poetry and Version Plugin run: pip install "poetry<2" poetry-version-from-file @@ -57,5 +62,8 @@ jobs: - name: Check for version update id: extract_update run: | - python3 utils/.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}" - echo "uped_toml=true" >> $GITHUB_OUTPUT + if python3 utils/.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}"; then + echo "uped_toml=true" >> $GITHUB_OUTPUT + else + echo "uped_toml=false" >> $GITHUB_OUTPUT + fi diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index b87e520..cc7d444 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -6,6 +6,14 @@ on: course_id: required: true type: string + course_info_path: + required: false + type: string + default: "" + global_args_path: + required: false + type: string + default: "" secrets: discord_role: required: true @@ -39,7 +47,7 @@ jobs: - name: Install notification dependencies shell: bash - run: pip install discord-webhook ${{ inputs.extra-packages }} + run: pip install discord-webhook pyyaml - name: Get changed files run: | @@ -78,7 +86,7 @@ jobs: - name: Send course notification if: always() - uses: ./.github/actions/send-course-notification + uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@canvas-notification-updates with: notification-type: docker output-type: Docker @@ -88,3 +96,5 @@ jobs: course-id: ${{ inputs.course_id }} discord-role: ${{ secrets.discord_role }} discord-webhook-url: ${{ secrets.discord_webhook_url }} + course-info-path: ${{ inputs.course_info_path && format('{0}/{1}', github.workspace, inputs.course_info_path) || '' }} + global-args-path: ${{ inputs.global_args_path && format('{0}/{1}', github.workspace, inputs.global_args_path) || '' }} diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index 9917b31..85776b4 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -61,7 +61,7 @@ jobs: - name: Install notification dependencies shell: bash - run: pip install discord-webhook ${{ inputs.extra-packages }} + run: pip install discord-webhook - name: Create logs directory run: mkdir -p "$LOGS_DIR" @@ -105,3 +105,5 @@ jobs: course-id: ${{ inputs.course_id }} discord-role: ${{ secrets.discord_role }} discord-webhook-url: ${{ secrets.discord_webhook_url }} + course-info-path: ${{ github.workspace }}/${{ inputs.course_info_path }} + global-args-path: ${{ github.workspace }}/${{ inputs.global_args_path }} diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index e19067c..804b7cb 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -7,15 +7,15 @@ on: required: true type: string secrets: - pypi_user: - required: true - pypi_password: - required: true discord_webhook_url: required: true discord_role: required: true +permissions: + contents: read + id-token: write + jobs: check-toml-version: uses: ./.github/workflows/check_toml.yaml @@ -27,7 +27,7 @@ jobs: needs: check-toml-version runs-on: ubuntu-latest outputs: - success: ${{ steps.publish.outputs.success }} + success: ${{ steps.result.outputs.success }} version: ${{ needs.check-toml-version.outputs.version }} steps: - name: Checkout repository @@ -42,25 +42,33 @@ jobs: - name: Build PyPI package run: poetry build - - name: Publish PyPI package - id: publish - env: - POETRY_PYPI_USERNAME: ${{ secrets.pypi_user }} - POETRY_PYPI_PASSWORD: ${{ secrets.pypi_password }} + - name: Check version was bumped + id: check run: | if [ "${{ needs.check-toml-version.outputs.uped_toml }}" != "true" ]; then echo "pyproject.toml version was not updated — skipping publish" - echo "success=false" >> $GITHUB_OUTPUT - exit 1 + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT fi - if poetry publish --username "$POETRY_PYPI_USERNAME" --password "$POETRY_PYPI_PASSWORD"; then - echo "Publish succeeded" + - name: Publish to PyPI via trusted publisher + id: publish + if: steps.check.outputs.skip != 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + print-hash: true + + - name: Set publish result + id: result + if: always() + run: | + if [ "${{ steps.check.outputs.skip }}" == "true" ]; then + echo "success=false" >> $GITHUB_OUTPUT + elif [ "${{ steps.publish.outcome }}" == "success" ]; then echo "success=true" >> $GITHUB_OUTPUT else - echo "Publish failed" echo "success=false" >> $GITHUB_OUTPUT - exit 1 fi notify-discord: @@ -76,10 +84,10 @@ jobs: - name: Install notification dependencies shell: bash - run: pip install discord-webhook ${{ inputs.extra-packages }} + run: pip install discord-webhook - name: Send PyPI notification - uses: utils/.github/actions/send-pypi-notification + uses: BYU-CS-Course-Ops/utils/.github/actions/send-pypi-notification@canvas-notification-updates with: pypi-package: ${{ inputs.pypi_package }} success: ${{ needs.poetry-publish.outputs.success }} @@ -87,3 +95,4 @@ jobs: version: ${{ needs.poetry-publish.outputs.version }} discord-role: ${{ secrets.discord_role }} discord-webhook-url: ${{ secrets.discord_webhook_url }} + old-version: ${{ needs.check-toml-version.outputs.pypi_version }} diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index b613eb2..f11ed6e 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -1,7 +1,10 @@ from datetime import datetime from notifications.resources import Notification, Embed, Field, Author, Footer -from notifications.formatting.formatting_utils import spacer, generate_fields, truncate_error, get_course_style, hex_to_int +from notifications.formatting.formatting_utils import ( + spacer, generate_fields, truncate_error, get_course_style, hex_to_int, + build_resource_summary, resource_count_fields, course_info_field, +) def has_content(data) -> bool: @@ -16,25 +19,74 @@ def requires_review(data) -> bool: return bool(data["content_to_review"] or data["error"]) -def format_notification(data, course_id, author, author_icon, branch, action_url) -> Notification: +def format_notification(data, course_id, author, author_icon, branch, action_url, + course_name=None, course_url=None) -> Notification: style = get_course_style("canvas") - deployed_content = ( - '\n'.join(f'- **{rtype}**: [{content}]({link})' if link else f'- **{rtype}**: {content}' - for rtype, content, link in data['deployed_content'])) \ - if data['deployed_content'] \ - else '*No items deployed*' + # Build fields list with actionable items first + fields: list[Field] = [] + # Course info + ci_field = course_info_field(course_name, course_url) + if ci_field: + fields.append(ci_field) + + # Resource type summary counts + if data['deployed_content']: + counts = build_resource_summary(data['deployed_content']) + fields.append(spacer()) + fields.append(Field(name="**Deployment Summary:**", value="\u200b", inline=False)) + fields.extend(resource_count_fields(counts)) + + # Content to Review -- actionable, shown first content_to_review = ( '\n'.join(f'- [{dat[0]}]({dat[1]})' for dat in data['content_to_review'])) \ if data['content_to_review'] \ else '*No items to review*' + fields.append(spacer()) + fields.extend(generate_fields( + name='**Content to Review:**', + value=content_to_review, + inline=False, + )) + + # Deployed Content links + deployed_content = ( + '\n'.join(f'- **{rtype}**: [{content}]({link})' if link else f'- **{rtype}**: {content}' + for rtype, content, link in data['deployed_content'])) \ + if data['deployed_content'] \ + else '*No items deployed*' + + fields.append(spacer()) + fields.extend(generate_fields( + name='**Deployed Content:**', + value=deployed_content, + inline=False, + )) + + # Error error = data["error"] if data['error'] else '*No errors*' if error != '*No errors*': error = truncate_error(error) + fields.append(spacer()) + fields.extend(generate_fields( + name='**Error:**', + value=error, + inline=False, + )) + + # GitHub Action link + fields.append(spacer()) + fields.append(Field( + name='**GitHub Action:**', + value=f'[View here]({action_url})', + inline=False, + )) + fields.append(spacer()) + return Notification( username=style["username"], avatar_url=style["avatar_url"], @@ -48,32 +100,6 @@ def format_notification(data, course_id, author, author_icon, branch, action_url text=style["footer_text"], icon_url=style["footer_icon_url"], ), - fields=[ - spacer(), - *generate_fields( - name='**Deployed Content:**', - value=deployed_content, - inline=False, - ), - spacer(), - *generate_fields( - name='**Content to Review:**', - value=content_to_review, - inline=False, - ), - spacer(), - *generate_fields( - name='**Error:**', - value=error, - inline=False, - ), - spacer(), - Field( - name='**GitHub Action:**', - value=f'[View here]({action_url})', - inline=False, - ), - spacer(), - ], + fields=fields, )], ) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index 5054425..782fffe 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -1,7 +1,10 @@ from datetime import datetime from notifications.resources import Notification, Embed, Field, Author, Footer -from notifications.formatting.formatting_utils import spacer, generate_fields, truncate_error, get_course_style, hex_to_int +from notifications.formatting.formatting_utils import ( + spacer, generate_fields, truncate_error, get_course_style, hex_to_int, + course_info_field, +) def has_content(data) -> bool: @@ -16,25 +19,74 @@ def requires_review(data) -> bool: return bool(data["failed_images"] or data["error"]) -def format_notification(data, course_id, author, author_icon, branch, action_url) -> Notification: +def format_notification(data, course_id, author, author_icon, branch, action_url, + course_name=None, course_url=None) -> Notification: style = get_course_style("docker") + # Build fields list with actionable items first + fields: list[Field] = [] + + # Course info + ci_field = course_info_field(course_name, course_url) + if ci_field: + fields.append(ci_field) + + # Summary counts + updated_count = len(data['updated_images']) + failed_count = len(data['failed_images']) + fields.append(spacer()) + fields.append(Field(name="Updated", value=f"**{updated_count}**", inline=True)) + fields.append(Field(name="Failed", value=f"**{failed_count}**", inline=True)) + + # Failed Images -- actionable, shown first + failed_images = ( + '\n'.join(f'- {image}' + for image in data['failed_images'])) \ + if data['failed_images'] \ + else '*No failed images*' + + fields.append(spacer()) + fields.extend(generate_fields( + name='**Failed Image(s):**', + value=failed_images, + inline=False, + )) + + # Updated Images updated_images = ( '\n'.join(f'- {image}' for image in data['updated_images'])) \ if data['updated_images'] \ else '*No updated images*' - failed_images = ( - '\n'.join(f'- {image}' - for image in data['failed_images'])) \ - if data['failed_images'] \ - else '*No items to review*' + fields.append(spacer()) + fields.extend(generate_fields( + name='**Updated Image(s):**', + value=updated_images, + inline=False, + )) + # Error error = data["error"] if data['error'] else '*No errors*' if error != '*No errors*': error = truncate_error(error) + fields.append(spacer()) + fields.extend(generate_fields( + name='**Error:**', + value=error, + inline=False, + )) + + # GitHub Action link + fields.append(spacer()) + fields.append(Field( + name='**GitHub Action:**', + value=f'[View here]({action_url})', + inline=False, + )) + fields.append(spacer()) + return Notification( username=style["username"], avatar_url=style["avatar_url"], @@ -48,32 +100,6 @@ def format_notification(data, course_id, author, author_icon, branch, action_url text=style["footer_text"], icon_url=style["footer_icon_url"], ), - fields=[ - spacer(), - *generate_fields( - name='**Updated Image(s):**', - value=updated_images, - inline=True, - ), - spacer(), - *generate_fields( - name='**Failed Image(s):**', - value=failed_images, - inline=True, - ), - spacer(), - *generate_fields( - name='**Error:**', - value=error, - inline=False, - ), - spacer(), - Field( - name='**GitHub Action:**', - value=f'[View here]({action_url})', - inline=False, - ), - spacer(), - ], + fields=fields, )], ) diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index 4c35ab3..30fdea3 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -85,6 +85,36 @@ def generate_fields(name: str, value: str, inline: bool = False) -> list[Field]: return [Field(name=name, value=value, inline=inline)] +def build_resource_summary(deployed_content: list) -> dict[str, int]: + """Count deployed items by resource type from deployed_content tuples.""" + counts: dict[str, int] = {} + for rtype, _name, _link in deployed_content: + label = rtype.title() + counts[label] = counts.get(label, 0) + 1 + return counts + + +def resource_count_fields(counts: dict[str, int]) -> list[Field]: + """Produce inline Discord fields for resource type counts (3 per row).""" + return [ + Field(name=rtype, value=f"**{count}**", inline=True) + for rtype, count in counts.items() + ] + + +def course_info_field(course_name: str | None, course_url: str | None) -> Field | None: + """Create a clickable course link field. Returns None if data is missing.""" + if course_name and course_url: + return Field( + name="**Course:**", + value=f"[{course_name}]({course_url})", + inline=False, + ) + elif course_name: + return Field(name="**Course:**", value=course_name, inline=False) + return None + + def truncate_error(error: str, max_chars: int = 900) -> str: if not error: return "```\nNo error output available.\n```" diff --git a/notifications/formatting/pypi_format.py b/notifications/formatting/pypi_format.py index adf7c5b..3e17377 100644 --- a/notifications/formatting/pypi_format.py +++ b/notifications/formatting/pypi_format.py @@ -4,14 +4,37 @@ from notifications.formatting.formatting_utils import spacer, get_pypi_style, hex_to_int -def format_notification(ntype, author, author_icon, action_url, success, version) -> Notification: +def format_notification(ntype, author, author_icon, action_url, success, version, + old_version=None) -> Notification: style = get_pypi_style(ntype) + pypi_name = style.get("pypi_name", ntype) if success: - description = f"Updated to version **`{version}`**" + if old_version: + description = f"**`{old_version}`** \u2192 **`{version}`**" + else: + description = f"Updated to version **`{version}`**" else: description = f"An **error occurred** while updating {style['display_name']}." + fields: list[Field] = [spacer()] + + # PyPI package link on success + if success and version: + fields.append(Field( + name="**PyPI Package:**", + value=f"[{pypi_name} v{version}](https://pypi.org/project/{pypi_name}/{version}/)", + inline=False, + )) + fields.append(spacer()) + + fields.append(Field( + name="**GitHub Action:**", + value=f"[View Here]({action_url})", + inline=False, + )) + fields.append(spacer()) + return Notification( username=style["username"], embeds=[Embed( @@ -24,14 +47,6 @@ def format_notification(ntype, author, author_icon, action_url, success, version text=style["footer_text"], icon_url=style["footer_icon_url"], ), - fields=[ - spacer(), - Field( - name="GitHub Action:", - value=f"[View Here]({action_url})", - inline=False, - ), - spacer(), - ], + fields=fields, )], ) diff --git a/notifications/formatting/style.md b/notifications/formatting/style.md index bdd5d0d..f5ae6a4 100644 --- a/notifications/formatting/style.md +++ b/notifications/formatting/style.md @@ -7,8 +7,8 @@ # PyPi Packages -| type | username | display_name | title | hex_color | footer_text | footer_icon_url | -|------------------|--------------------------------|------------------|-------------------------|-----------|--------------------------------|------------------------------| -| mdxcanvas | MDXCanvas Notifications | MDXCanvas | MDXCanvas Update | #F56236 | MDXCanvas GitHub Action | https://tinyurl.com/4ky2afzx | -| markdowndata | MarkdownData Notifications | MarkdownData | MarkdownData Update | #13DC56 | MarkdownData GitHub Action | https://tinyurl.com/4ky2afzx | -| byu_pytest_utils | BYU Pytest Utils Notifications | BYU Pytest Utils | BYU Pytest Utils Update | #3498DB | BYU Pytest Utils GitHub Action | https://tinyurl.com/4ky2afzx | +| type | pypi_name | username | display_name | title | hex_color | footer_text | footer_icon_url | +|------------------|------------------|--------------------------------|------------------|-------------------------|-----------|--------------------------------|------------------------------| +| mdxcanvas | mdxcanvas | MDXCanvas Notifications | MDXCanvas | MDXCanvas Update | #F56236 | MDXCanvas GitHub Action | https://tinyurl.com/4ky2afzx | +| markdowndata | markdowndata | MarkdownData Notifications | MarkdownData | MarkdownData Update | #13DC56 | MarkdownData GitHub Action | https://tinyurl.com/4ky2afzx | +| byu_pytest_utils | byu-pytest-utils | BYU Pytest Utils Notifications | BYU Pytest Utils | BYU Pytest Utils Update | #3498DB | BYU Pytest Utils GitHub Action | https://tinyurl.com/4ky2afzx | diff --git a/notifications/send_course.py b/notifications/send_course.py index 92a29e8..f5a9fb3 100644 --- a/notifications/send_course.py +++ b/notifications/send_course.py @@ -12,7 +12,8 @@ } -def main(ntype, payload, course_id, author, author_icon, branch_name, action_url, cicd_role_id): +def main(ntype, payload, course_id, author, author_icon, branch_name, action_url, cicd_role_id, + course_name=None, course_url=None): webhook_url = os.getenv("DISCORD_WEBHOOK_URL") if not webhook_url: raise EnvironmentError("DISCORD_WEBHOOK_URL environment variable is not set.") @@ -36,6 +37,8 @@ def main(ntype, payload, course_id, author, author_icon, branch_name, action_url author_icon=author_icon, branch=branch_name, action_url=action_url, + course_name=course_name, + course_url=course_url, ) if requires_review(data) and cicd_role_id: @@ -54,7 +57,10 @@ def main(ntype, payload, course_id, author, author_icon, branch_name, action_url parser.add_argument("--branch", required=True, help="Branch name") parser.add_argument("--action-url", required=True, help="URL to the GHA") parser.add_argument("--cicd-id", nargs='?', const=None, default=None, help="CI/CD Role ID") + parser.add_argument("--course-name", default=None, help="Course name for display") + parser.add_argument("--course-url", default=None, help="URL to the course page") args = parser.parse_args() - main(args.type, args.payload, args.course_id, args.author, args.author_icon, args.branch, args.action_url, args.cicd_id) + main(args.type, args.payload, args.course_id, args.author, args.author_icon, args.branch, args.action_url, + args.cicd_id, args.course_name, args.course_url) diff --git a/notifications/send_pypi.py b/notifications/send_pypi.py index 98b5e60..45d5327 100644 --- a/notifications/send_pypi.py +++ b/notifications/send_pypi.py @@ -5,7 +5,8 @@ from .send_notification import send_notification -def main(ntype, author, author_icon, action_url, success=None, version=None, cicd_role_id=None): +def main(ntype, author, author_icon, action_url, success=None, version=None, + cicd_role_id=None, old_version=None): webhook_url = os.getenv("DISCORD_WEBHOOK_URL") if not webhook_url: raise EnvironmentError("DISCORD_WEBHOOK_URL environment variable is not set.") @@ -17,6 +18,7 @@ def main(ntype, author, author_icon, action_url, success=None, version=None, cic action_url=action_url, success=success, version=version, + old_version=old_version, ) if not success and cicd_role_id: @@ -35,7 +37,9 @@ def main(ntype, author, author_icon, action_url, success=None, version=None, cic parser.add_argument("--success", nargs='?', const=None, default=None, help="Bool indicating success or failure") parser.add_argument("--version", nargs='?', const=None, default=None, help="PyPi version") parser.add_argument("--cicd-id", nargs='?', const=None, default=None, help="CI/CD Role ID") + parser.add_argument("--old-version", default=None, help="Previous PyPI version for transition display") args = parser.parse_args() - main(args.type, args.author, args.author_icon, args.action_url, args.success, args.version, args.cicd_id) + main(args.type, args.author, args.author_icon, args.action_url, args.success, args.version, + args.cicd_id, args.old_version) From f57e22e3105dc8d00dac6a0224bc67a8ba203150 Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 14:54:58 -0600 Subject: [PATCH 15/32] Fix action references to use claude/wonderful-lovelace branch All composite action uses: references were still pointing to canvas-notification-updates instead of this branch. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker_automation.yaml | 2 +- .github/workflows/mdxcanvas_automation.yaml | 4 ++-- .github/workflows/poetry_publish.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index cc7d444..0b18679 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -86,7 +86,7 @@ jobs: - name: Send course notification if: always() - uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@canvas-notification-updates + uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@claude/wonderful-lovelace with: notification-type: docker output-type: Docker diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index 85776b4..d4a89f0 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -53,7 +53,7 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils - ref: canvas-notification-updates + ref: claude/wonderful-lovelace path: utils - name: Install MDXCanvas @@ -95,7 +95,7 @@ jobs: cat "$LOGS_DIR/mdxcanvas.stderr.log" - name: Send course notification - uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@canvas-notification-updates + uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@claude/wonderful-lovelace with: notification-type: canvas output-type: MDXCanvas diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 804b7cb..7ddd406 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -87,7 +87,7 @@ jobs: run: pip install discord-webhook - name: Send PyPI notification - uses: BYU-CS-Course-Ops/utils/.github/actions/send-pypi-notification@canvas-notification-updates + uses: BYU-CS-Course-Ops/utils/.github/actions/send-pypi-notification@claude/wonderful-lovelace with: pypi-package: ${{ inputs.pypi_package }} success: ${{ needs.poetry-publish.outputs.success }} From d99d6b9a6a2c0150d56d0683b75b1844594b04cd Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 14:58:54 -0600 Subject: [PATCH 16/32] Handle null avatar URLs gracefully - Use jq '// empty' to avoid outputting literal "null" string - Only pass icon_url to set_author when it's truthy - Prevents Discord 400 errors from invalid author icon URLs Co-Authored-By: Claude Opus 4.6 --- .github/actions/send-course-notification/action.yml | 4 ++-- .github/actions/send-pypi-notification/action.yml | 4 ++-- notifications/send_notification.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/actions/send-course-notification/action.yml b/.github/actions/send-course-notification/action.yml index 2147eb0..7add631 100644 --- a/.github/actions/send-course-notification/action.yml +++ b/.github/actions/send-course-notification/action.yml @@ -52,8 +52,8 @@ runs: id: avatar shell: bash run: | - AVATAR_URL=$(curl -s "https://api.github.com/users/${{ github.actor }}" | jq -r '.avatar_url') - echo "url=$AVATAR_URL" >> $GITHUB_OUTPUT + AVATAR_URL=$(curl -s "https://api.github.com/users/${{ github.actor }}" | jq -r '.avatar_url // empty') + echo "url=${AVATAR_URL:-}" >> $GITHUB_OUTPUT - name: Extract course metadata id: course-meta diff --git a/.github/actions/send-pypi-notification/action.yml b/.github/actions/send-pypi-notification/action.yml index bf02d0c..7472957 100644 --- a/.github/actions/send-pypi-notification/action.yml +++ b/.github/actions/send-pypi-notification/action.yml @@ -32,8 +32,8 @@ runs: id: avatar shell: bash run: | - AVATAR_URL=$(curl -s "https://api.github.com/users/${{ github.actor }}" | jq -r '.avatar_url') - echo "url=$AVATAR_URL" >> $GITHUB_OUTPUT + AVATAR_URL=$(curl -s "https://api.github.com/users/${{ github.actor }}" | jq -r '.avatar_url // empty') + echo "url=${AVATAR_URL:-}" >> $GITHUB_OUTPUT - name: Send PyPI notification to Discord shell: bash diff --git a/notifications/send_notification.py b/notifications/send_notification.py index 174ee99..8b8c200 100644 --- a/notifications/send_notification.py +++ b/notifications/send_notification.py @@ -114,10 +114,10 @@ def send_notification(webhook_url: str, notification: Notification): ) if embed_data.author: - embed.set_author( - name=embed_data.author.name, - icon_url=embed_data.author.icon_url, - ) + author_kwargs = {"name": embed_data.author.name} + if embed_data.author.icon_url: + author_kwargs["icon_url"] = embed_data.author.icon_url + embed.set_author(**author_kwargs) if embed_data.footer: embed.set_footer( From 2e5e4eedf1a0ce78ee227bcaf195e77793168618 Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 15:00:47 -0600 Subject: [PATCH 17/32] Move permissions from workflow level to job level Reusable workflows (workflow_call) don't support workflow-level permissions. Move id-token: write to the poetry-publish job that needs it for OIDC trusted publisher auth. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/poetry_publish.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 7ddd406..f0a6008 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -12,10 +12,6 @@ on: discord_role: required: true -permissions: - contents: read - id-token: write - jobs: check-toml-version: uses: ./.github/workflows/check_toml.yaml @@ -26,6 +22,9 @@ jobs: poetry-publish: needs: check-toml-version runs-on: ubuntu-latest + permissions: + contents: read + id-token: write outputs: success: ${{ steps.result.outputs.success }} version: ${{ needs.check-toml-version.outputs.version }} From a8d697fc091292ddc11cf7ffd50dc1417e09615c Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 15:03:56 -0600 Subject: [PATCH 18/32] Fix nested reusable workflow: use full repo ref for check_toml.yaml The ./ relative path resolves to the calling repo, not this repo, causing startup_failure when called cross-repo. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/poetry_publish.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index f0a6008..decd7e3 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -14,7 +14,7 @@ on: jobs: check-toml-version: - uses: ./.github/workflows/check_toml.yaml + uses: BYU-CS-Course-Ops/utils/.github/workflows/check_toml.yaml@claude/wonderful-lovelace with: pypi_package: ${{ inputs.pypi_package }} secrets: inherit From e9252676ec1b987e1cf14e3a091f12bf8acf6537 Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 15:09:46 -0600 Subject: [PATCH 19/32] Remove secrets: inherit from nested check_toml call check_toml.yaml doesn't declare any secrets, so inherit may cause validation issues on the nested reusable workflow call. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/poetry_publish.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index decd7e3..b71a58e 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -17,7 +17,6 @@ jobs: uses: BYU-CS-Course-Ops/utils/.github/workflows/check_toml.yaml@claude/wonderful-lovelace with: pypi_package: ${{ inputs.pypi_package }} - secrets: inherit poetry-publish: needs: check-toml-version From 5c19bd211b9de465cb46aeb33f8d83ef4c0b88ee Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 15:10:59 -0600 Subject: [PATCH 20/32] Inline check_toml logic into poetry_publish to fix startup_failure Nested reusable workflow calls across repos cause startup_failure. Inlining eliminates the nesting and removes duplicated poetry install/build steps that ran in both jobs. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/poetry_publish.yaml | 49 +++++++++++++++++++++------ 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index b71a58e..d777985 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -13,23 +13,35 @@ on: required: true jobs: - check-toml-version: - uses: BYU-CS-Course-Ops/utils/.github/workflows/check_toml.yaml@claude/wonderful-lovelace - with: - pypi_package: ${{ inputs.pypi_package }} - poetry-publish: - needs: check-toml-version runs-on: ubuntu-latest permissions: contents: read id-token: write outputs: success: ${{ steps.result.outputs.success }} - version: ${{ needs.check-toml-version.outputs.version }} + version: ${{ steps.get_build_version.outputs.version }} + uped_toml: ${{ steps.extract_update.outputs.uped_toml }} + pypi_version: ${{ steps.get_pypi_version.outputs.pypi_version }} steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils + path: utils + + - name: Get version from PyPI + id: get_pypi_version + run: | + VERSION=$(curl -s https://pypi.org/pypi/${{ inputs.pypi_package }}/json | jq -r .info.version) + echo "pypi_version=$VERSION" >> $GITHUB_ENV + echo "pypi_version=$VERSION" >> $GITHUB_OUTPUT - name: Install Poetry and Version Plugin run: pip install "poetry<2" poetry-version-from-file @@ -40,10 +52,25 @@ jobs: - name: Build PyPI package run: poetry build + - name: Get version from poetry build + id: get_build_version + run: | + VERSION=$(poetry version -s) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Check for version update + id: extract_update + run: | + if python3 utils/.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}"; then + echo "uped_toml=true" >> $GITHUB_OUTPUT + else + echo "uped_toml=false" >> $GITHUB_OUTPUT + fi + - name: Check version was bumped id: check run: | - if [ "${{ needs.check-toml-version.outputs.uped_toml }}" != "true" ]; then + if [ "${{ steps.extract_update.outputs.uped_toml }}" != "true" ]; then echo "pyproject.toml version was not updated — skipping publish" echo "skip=true" >> $GITHUB_OUTPUT else @@ -70,7 +97,7 @@ jobs: fi notify-discord: - needs: [check-toml-version, poetry-publish] + needs: poetry-publish runs-on: ubuntu-latest if: always() steps: @@ -89,8 +116,8 @@ jobs: with: pypi-package: ${{ inputs.pypi_package }} success: ${{ needs.poetry-publish.outputs.success }} - toml-updated: ${{ needs.check-toml-version.outputs.uped_toml }} + toml-updated: ${{ needs.poetry-publish.outputs.uped_toml }} version: ${{ needs.poetry-publish.outputs.version }} discord-role: ${{ secrets.discord_role }} discord-webhook-url: ${{ secrets.discord_webhook_url }} - old-version: ${{ needs.check-toml-version.outputs.pypi_version }} + old-version: ${{ needs.poetry-publish.outputs.pypi_version }} From 865e68a55e6480ace40656390db720c777073971 Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 15:11:39 -0600 Subject: [PATCH 21/32] Test: remove permissions block to debug startup_failure Co-Authored-By: Claude Opus 4.6 --- .github/workflows/poetry_publish.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index d777985..b4a2de4 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -15,9 +15,6 @@ on: jobs: poetry-publish: runs-on: ubuntu-latest - permissions: - contents: read - id-token: write outputs: success: ${{ steps.result.outputs.success }} version: ${{ steps.get_build_version.outputs.version }} From 2c43a7ad84dc4fcfa36e5d49911ce4a8dcc1b332 Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 15:16:33 -0600 Subject: [PATCH 22/32] Fix utils checkout refs and null PyPI version handling All utils repo checkouts now specify the branch ref so the notifications package is available. PyPI version query uses jq '// empty' to avoid passing literal 'null' strings. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/check_toml.yaml | 7 ++++--- .github/workflows/docker_automation.yaml | 1 + .github/workflows/poetry_publish.yaml | 8 +++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check_toml.yaml b/.github/workflows/check_toml.yaml index c62892f..eec217f 100644 --- a/.github/workflows/check_toml.yaml +++ b/.github/workflows/check_toml.yaml @@ -35,14 +35,15 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils + ref: claude/wonderful-lovelace path: utils - name: Get version from PyPI id: get_pypi_version run: | - VERSION=$(curl -s https://pypi.org/pypi/${{ inputs.pypi_package }}/json | jq -r .info.version) - echo "pypi_version=$VERSION" >> $GITHUB_ENV - echo "pypi_version=$VERSION" >> $GITHUB_OUTPUT + VERSION=$(curl -s https://pypi.org/pypi/${{ inputs.pypi_package }}/json | jq -r '.info.version // empty') + echo "pypi_version=${VERSION:-}" >> $GITHUB_ENV + echo "pypi_version=${VERSION:-}" >> $GITHUB_OUTPUT - name: Install Poetry and Version Plugin run: pip install "poetry<2" poetry-version-from-file diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index 0b18679..3bb0592 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -43,6 +43,7 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils + ref: claude/wonderful-lovelace path: utils - name: Install notification dependencies diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index b4a2de4..584f529 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -31,14 +31,15 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils + ref: claude/wonderful-lovelace path: utils - name: Get version from PyPI id: get_pypi_version run: | - VERSION=$(curl -s https://pypi.org/pypi/${{ inputs.pypi_package }}/json | jq -r .info.version) - echo "pypi_version=$VERSION" >> $GITHUB_ENV - echo "pypi_version=$VERSION" >> $GITHUB_OUTPUT + VERSION=$(curl -s https://pypi.org/pypi/${{ inputs.pypi_package }}/json | jq -r '.info.version // empty') + echo "pypi_version=${VERSION:-}" >> $GITHUB_ENV + echo "pypi_version=${VERSION:-}" >> $GITHUB_OUTPUT - name: Install Poetry and Version Plugin run: pip install "poetry<2" poetry-version-from-file @@ -102,6 +103,7 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils + ref: claude/wonderful-lovelace path: utils - name: Install notification dependencies From 8e0a7ca2d54c8db3e652afa053c9565f232fd0c5 Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 15:18:46 -0600 Subject: [PATCH 23/32] Add markdowndata dependency to all notification workflow steps The notifications package imports markdowndata for style parsing, but it wasn't being installed in pip install steps. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker_automation.yaml | 2 +- .github/workflows/mdxcanvas_automation.yaml | 2 +- .github/workflows/poetry_publish.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index 3bb0592..487d396 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -48,7 +48,7 @@ jobs: - name: Install notification dependencies shell: bash - run: pip install discord-webhook pyyaml + run: pip install discord-webhook pyyaml markdowndata - name: Get changed files run: | diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index d4a89f0..a65545a 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -61,7 +61,7 @@ jobs: - name: Install notification dependencies shell: bash - run: pip install discord-webhook + run: pip install discord-webhook markdowndata - name: Create logs directory run: mkdir -p "$LOGS_DIR" diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 584f529..86e205b 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -108,7 +108,7 @@ jobs: - name: Install notification dependencies shell: bash - run: pip install discord-webhook + run: pip install discord-webhook markdowndata - name: Send PyPI notification uses: BYU-CS-Course-Ops/utils/.github/actions/send-pypi-notification@claude/wonderful-lovelace From 3d0cd735fe9f6633ab78a41ead908a6b2d8e4f8d Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 15:24:13 -0600 Subject: [PATCH 24/32] Restore id-token: write permission for OIDC PyPI publishing Calling workflows must also grant id-token: write permission for the trusted publisher flow to work. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/poetry_publish.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 86e205b..8459d3a 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -15,6 +15,9 @@ on: jobs: poetry-publish: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write outputs: success: ${{ steps.result.outputs.success }} version: ${{ steps.get_build_version.outputs.version }} From 1d87c4e765cacf26a547bde813316dd8c47de5cc Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 30 Mar 2026 15:27:16 -0600 Subject: [PATCH 25/32] Update all branch refs to @main for production Replace claude/wonderful-lovelace refs with main for all workflow checkout steps and composite action references. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/check_toml.yaml | 1 - .github/workflows/docker_automation.yaml | 3 +-- .github/workflows/mdxcanvas_automation.yaml | 3 +-- .github/workflows/poetry_publish.yaml | 4 +--- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/check_toml.yaml b/.github/workflows/check_toml.yaml index eec217f..d19014f 100644 --- a/.github/workflows/check_toml.yaml +++ b/.github/workflows/check_toml.yaml @@ -35,7 +35,6 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils - ref: claude/wonderful-lovelace path: utils - name: Get version from PyPI diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index 487d396..dfe9149 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -43,7 +43,6 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils - ref: claude/wonderful-lovelace path: utils - name: Install notification dependencies @@ -87,7 +86,7 @@ jobs: - name: Send course notification if: always() - uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@claude/wonderful-lovelace + uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@main with: notification-type: docker output-type: Docker diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index a65545a..10de8c2 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -53,7 +53,6 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils - ref: claude/wonderful-lovelace path: utils - name: Install MDXCanvas @@ -95,7 +94,7 @@ jobs: cat "$LOGS_DIR/mdxcanvas.stderr.log" - name: Send course notification - uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@claude/wonderful-lovelace + uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@main with: notification-type: canvas output-type: MDXCanvas diff --git a/.github/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 8459d3a..de37bbf 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -34,7 +34,6 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils - ref: claude/wonderful-lovelace path: utils - name: Get version from PyPI @@ -106,7 +105,6 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils - ref: claude/wonderful-lovelace path: utils - name: Install notification dependencies @@ -114,7 +112,7 @@ jobs: run: pip install discord-webhook markdowndata - name: Send PyPI notification - uses: BYU-CS-Course-Ops/utils/.github/actions/send-pypi-notification@claude/wonderful-lovelace + uses: BYU-CS-Course-Ops/utils/.github/actions/send-pypi-notification@main with: pypi-package: ${{ inputs.pypi_package }} success: ${{ needs.poetry-publish.outputs.success }} From 68713695187ef8dc018a5fe885652159316c7735 Mon Sep 17 00:00:00 2001 From: robbykap Date: Tue, 31 Mar 2026 00:07:19 -0600 Subject: [PATCH 26/32] Update README with OIDC migration guide and new workflow inputs Document the PyPI OIDC trusted publisher migration, add optional course_info_path/global_args_path to Docker example, and include post-merge cleanup notes. Co-Authored-By: Claude Opus 4.6 --- README.md | 42 +++++++++++++++++++++++++++++-- notifications/formatting/style.md | 10 ++++---- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b1fb779..805793d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ jobs: uses: BYU-CS-Course-Ops/utils/.github/workflows/docker_automation.yaml@main with: course_id: "235" + course_info_path: "_canvas-material/course-info/cs235_sp2025.json" # Optional + global_args_path: "_canvas-material/global_args.json" # Optional secrets: discord_role: ${{ secrets.CICD_NOTIFY_DISCORD_ROLE }} docker_user: ${{ secrets.DOCKER_USER }} @@ -66,6 +68,10 @@ jobs: ## Example usage for poetry_publish.yaml +> **Important:** This workflow uses PyPI OIDC trusted publishers instead of API tokens. +> The calling workflow **must** include `permissions: id-token: write` or publishing +> will fail with a `startup_failure` error and no logs. + ```yaml name: MDXCanvas Publish @@ -74,14 +80,46 @@ on: push: branches: [main] +permissions: + id-token: write + jobs: mdxcanvas_publish: uses: BYU-CS-Course-Ops/utils/.github/workflows/poetry_publish.yaml@main with: pypi_package: "mdxcanvas" secrets: - pypi_user: ${{ secrets.PYPI_USER }} - pypi_password: ${{ secrets.PYPI_PASSWORD }} discord_webhook_url: ${{ secrets.GHA_BEANLAB_DISCORD_WEBHOOK }} discord_role: ${{ secrets.CICD_NOTIFY_DISCORD_ROLE }} ``` + +## Migrating poetry_publish.yaml to OIDC + +The `poetry_publish.yaml` workflow now uses [PyPI trusted publishers](https://docs.pypi.org/trusted-publishers/) (OIDC) instead of username/password API tokens. + +### Steps to migrate + +1. **Configure a trusted publisher on PyPI** + - Go to your package on [pypi.org](https://pypi.org) > Manage > Publishing + - Add a new "GitHub Actions" publisher: + - **Owner:** your GitHub org (e.g., `BYU-CS-Course-Ops`) + - **Repository:** the repo that calls the workflow + - **Workflow name:** the filename of your *calling* workflow (e.g., `publish.yaml`), **not** `poetry_publish.yaml` + - **Environment:** leave blank + +2. **Add `permissions: id-token: write` to your calling workflow** + - This must be at the **workflow level**, not the job level + - Without this, GitHub Actions returns a `startup_failure` with no logs + +3. **Remove old secrets** + - Delete the `pypi_user` and `pypi_password` lines from your workflow's `secrets:` block + - Optionally remove the `PYPI_USER` and `PYPI_PASSWORD` repository secrets from GitHub Settings + +4. **Keep Discord secrets** — `discord_webhook_url` and `discord_role` are still required + +## Post-merge cleanup + +After merging PR #7, update the testing repo (`testkapua/testing-repo`) workflow refs from `@claude/wonderful-lovelace` to `@main`: +- `.github/workflows/mdxcanvas_automation.yaml` +- `.github/workflows/docker_automation.yaml` +- `.github/workflows/poetry_publish.yaml` diff --git a/notifications/formatting/style.md b/notifications/formatting/style.md index f5ae6a4..8e9c603 100644 --- a/notifications/formatting/style.md +++ b/notifications/formatting/style.md @@ -1,14 +1,14 @@ # Course -| type | username | avatar_url | hex_color | title_template | footer_text | footer_icon_url | -|--------|--------------------------|------------------------------|-----------|-------------------------------------|-------------------------|------------------------------| -| canvas | Canvas Notifications | https://tinyurl.com/ek4ytkan | #F2051D | CS {course_id} - Course Updates | MDXCanvas GitHub Action | https://tinyurl.com/4ky2afzx | -| docker | Gradescope Notifications | https://tinyurl.com/mr2fyjse | #03A1FC | CS {course_id} - Docker Updates | Docker GitHub Action | https://tinyurl.com/4ky2afzx | +| type | username | avatar_url | hex_color | title_template | footer_text | footer_icon_url | +| ------ | ------------------------ | ---------------------------- | --------- | ------------------------------- | ----------------------- | ---------------------------- | +| canvas | Canvas Notifications | https://tinyurl.com/ek4ytkan | #F2051D | CS {course_id} - Course Updates | MDXCanvas GitHub Action | https://tinyurl.com/4ky2afzx | +| docker | Gradescope Notifications | https://tinyurl.com/mr2fyjse | #03A1FC | CS {course_id} - Docker Updates | Docker GitHub Action | https://tinyurl.com/4ky2afzx | # PyPi Packages | type | pypi_name | username | display_name | title | hex_color | footer_text | footer_icon_url | -|------------------|------------------|--------------------------------|------------------|-------------------------|-----------|--------------------------------|------------------------------| +| ---------------- | ---------------- | ------------------------------ | ---------------- | ----------------------- | --------- | ------------------------------ | ---------------------------- | | mdxcanvas | mdxcanvas | MDXCanvas Notifications | MDXCanvas | MDXCanvas Update | #F56236 | MDXCanvas GitHub Action | https://tinyurl.com/4ky2afzx | | markdowndata | markdowndata | MarkdownData Notifications | MarkdownData | MarkdownData Update | #13DC56 | MarkdownData GitHub Action | https://tinyurl.com/4ky2afzx | | byu_pytest_utils | byu-pytest-utils | BYU Pytest Utils Notifications | BYU Pytest Utils | BYU Pytest Utils Update | #3498DB | BYU Pytest Utils GitHub Action | https://tinyurl.com/4ky2afzx | From 7b7ccaf1b79b2ee642044d3cd3fb0a85d771eda4 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 12:10:20 -0600 Subject: [PATCH 27/32] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7c7173d..04a9b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.pyc .idea/ -test_notification.py \ No newline at end of file +test_notification.py +.claude/worktrees/ \ No newline at end of file From 38f5f4aad25fb30e5193c91d0e4541de9808f4c5 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 12:42:01 -0600 Subject: [PATCH 28/32] Redesign course notifications as plain text --- .../send-course-notification/action.yml | 20 +- .github/workflows/docker_automation.yaml | 8 + .github/workflows/mdxcanvas_automation.yaml | 8 + README.md | 28 ++- notifications/formatting/canvas_format.py | 132 ++++++----- notifications/formatting/docker_format.py | 110 +++++---- notifications/formatting/plain_text_utils.py | 141 ++++++++++++ notifications/formatting/pypi_format.py | 48 ++-- notifications/resources.py | 11 +- notifications/send_course.py | 25 +- notifications/send_notification.py | 213 ++++++++++++++---- notifications/send_pypi.py | 4 +- tests/test_course_text_formatters.py | 138 ++++++++++++ ...est_chunking.py => test_embed_chunking.py} | 9 +- tests/test_plain_text_chunking.py | 64 ++++++ tests/test_workflow_wiring.py | 26 +++ 16 files changed, 764 insertions(+), 221 deletions(-) create mode 100644 notifications/formatting/plain_text_utils.py create mode 100644 tests/test_course_text_formatters.py rename tests/{test_chunking.py => test_embed_chunking.py} (94%) create mode 100644 tests/test_plain_text_chunking.py create mode 100644 tests/test_workflow_wiring.py diff --git a/.github/actions/send-course-notification/action.yml b/.github/actions/send-course-notification/action.yml index 219c66a..9f213ad 100644 --- a/.github/actions/send-course-notification/action.yml +++ b/.github/actions/send-course-notification/action.yml @@ -1,5 +1,5 @@ name: Send Course Notification -description: Create fallback output if needed, get GitHub avatar, and send course notification to Discord +description: Create fallback output if needed and send course notification to Discord inputs: notification-type: @@ -20,6 +20,12 @@ inputs: course-id: description: "Course ID" required: true + course-name: + description: "Course name" + required: true + course-url: + description: "Course URL" + required: true discord-role: description: "Discord CI/CD role ID" required: true @@ -40,13 +46,6 @@ runs: --stderr-log "${{ inputs.stderr-log }}" \ --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - - name: Get user avatar - id: avatar - shell: bash - run: | - AVATAR_URL=$(curl -s "https://api.github.com/users/${{ github.actor }}" | jq -r '.avatar_url') - echo "url=$AVATAR_URL" >> $GITHUB_OUTPUT - - name: Send course notification to Discord shell: bash env: @@ -56,8 +55,9 @@ runs: --type "${{ inputs.notification-type }}" \ --payload "${{ inputs.payload-path }}" \ --course-id "${{ inputs.course-id }}" \ + --course-name "${{ inputs.course-name }}" \ + --course-url "${{ inputs.course-url }}" \ --author "${{ github.actor }}" \ - --author-icon "${{ steps.avatar.outputs.url }}" \ - --branch "${{ github.ref }}" \ + --branch "${{ github.ref_name }}" \ --cicd-id "${{ inputs.discord-role }}" \ --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index b87e520..5a6c2ba 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -6,6 +6,12 @@ on: course_id: required: true type: string + course_name: + required: true + type: string + course_url: + required: true + type: string secrets: discord_role: required: true @@ -86,5 +92,7 @@ jobs: stdout-log: ${{ github.workspace }}/.github/logs/docker_stdout.log stderr-log: ${{ github.workspace }}/.github/logs/docker_stderr.log course-id: ${{ inputs.course_id }} + course-name: ${{ inputs.course_name }} + course-url: ${{ inputs.course_url }} discord-role: ${{ secrets.discord_role }} discord-webhook-url: ${{ secrets.discord_webhook_url }} diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index 9917b31..b853d03 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -6,6 +6,12 @@ on: course_id: required: true type: string + course_name: + required: true + type: string + course_url: + required: true + type: string mdxcanvas_version: required: true type: string @@ -103,5 +109,7 @@ jobs: stdout-log: ${{ env.LOGS_DIR }}/mdxcanvas.stdout.log stderr-log: ${{ env.LOGS_DIR }}/mdxcanvas.stderr.log course-id: ${{ inputs.course_id }} + course-name: ${{ inputs.course_name }} + course-url: ${{ inputs.course_url }} discord-role: ${{ secrets.discord_role }} discord-webhook-url: ${{ secrets.discord_webhook_url }} diff --git a/README.md b/README.md index b1fb779..e75b7ee 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,14 @@ on: jobs: docker_automation: - uses: BYU-CS-Course-Ops/utils/.github/workflows/docker_automation.yaml@main - with: - course_id: "235" - secrets: - discord_role: ${{ secrets.CICD_NOTIFY_DISCORD_ROLE }} - docker_user: ${{ secrets.DOCKER_USER }} + uses: BYU-CS-Course-Ops/utils/.github/workflows/docker_automation.yaml@main + with: + course_id: "235" + course_name: "CS 235 Spring 2025" + course_url: "https://example.com/courses/cs235" + secrets: + discord_role: ${{ secrets.CICD_NOTIFY_DISCORD_ROLE }} + docker_user: ${{ secrets.DOCKER_USER }} docker_password: ${{ secrets.DOCKER_PASSWORD }} discord_webhook_url: ${{ secrets.GHA_235_DISCORD_WEBHOOK }} ``` @@ -32,12 +34,14 @@ on: jobs: update-canvas: - uses: BYU-CS-Course-Ops/utils/.github/workflows/mdxcanvas_automation.yaml@main - with: - course_id: "235" - mdxcanvas_version: "0.3.0" - course_info_path: "_canvas-material/course-info/cs235_sp2025.json" - global_args_path: "_canvas-material/global_args.json" + uses: BYU-CS-Course-Ops/utils/.github/workflows/mdxcanvas_automation.yaml@main + with: + course_id: "235" + course_name: "CS 235 Spring 2025" + course_url: "https://example.com/courses/cs235" + mdxcanvas_version: "0.3.0" + course_info_path: "_canvas-material/course-info/cs235_sp2025.json" + global_args_path: "_canvas-material/global_args.json" canvas_css_path: "_canvas-material/canvas.css" # Optional template_path: "_canvas-material/course.canvas.md.xml.jinja" secrets: diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index b613eb2..91f990f 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -1,7 +1,15 @@ -from datetime import datetime - -from notifications.resources import Notification, Embed, Field, Author, Footer -from notifications.formatting.formatting_utils import spacer, generate_fields, truncate_error, get_course_style, hex_to_int +from notifications.formatting.formatting_utils import get_course_style, truncate_error +from notifications.formatting.plain_text_utils import ( + build_bullet_sections, + build_header_section, + build_text_section, + dedupe_remaining_content, + format_link_items, + pluralize, + render_summary_table, + summarize_names, +) +from notifications.resources import Notification, WebhookMessage def has_content(data) -> bool: @@ -16,64 +24,76 @@ def requires_review(data) -> bool: return bool(data["content_to_review"] or data["error"]) -def format_notification(data, course_id, author, author_icon, branch, action_url) -> Notification: +def _build_canvas_summary(data: dict) -> str: + deployed_count = len(data["deployed_content"]) + review_count = len(data["content_to_review"]) + error_count = 1 if data["error"] else 0 + + summary = ( + f"{deployed_count} {pluralize(deployed_count, 'item')} deployed, " + f"{review_count} {pluralize(review_count, 'item')} need review, " + f"{error_count} {pluralize(error_count, 'error')}." + ) + + changed_items = summarize_names(content for _, content, _ in data["deployed_content"]) + if changed_items: + summary += f" Changed items: {changed_items}." + + return summary + + +def format_notification( + data, + course_id, + course_name, + course_url, + author, + author_icon, + branch, + action_url, +) -> Notification: style = get_course_style("canvas") - deployed_content = ( - '\n'.join(f'- **{rtype}**: [{content}]({link})' if link else f'- **{rtype}**: {content}' - for rtype, content, link in data['deployed_content'])) \ - if data['deployed_content'] \ - else '*No items deployed*' + deployed_count = len(data["deployed_content"]) + review_count = len(data["content_to_review"]) + error_count = 1 if data["error"] else 0 - content_to_review = ( - '\n'.join(f'- [{dat[0]}]({dat[1]})' - for dat in data['content_to_review'])) \ - if data['content_to_review'] \ - else '*No items to review*' + status_header = "Canvas update needs review" if requires_review(data) else "Canvas update posted" + summary_table = render_summary_table( + [ + ("Deployed", deployed_count), + ("Review", review_count), + ("Errors", error_count), + ] + ) - error = data["error"] if data['error'] else '*No errors*' - if error != '*No errors*': - error = truncate_error(error) + review_items = format_link_items(data["content_to_review"]) + remaining_items = format_link_items( + dedupe_remaining_content(data["deployed_content"], data["content_to_review"]) + ) + error = truncate_error(data["error"]) if data["error"] else None return Notification( username=style["username"], avatar_url=style["avatar_url"], - embeds=[Embed( - title=style["title_template"].format(course_id=course_id), - description=f'**`{branch}`**', - color=hex_to_int(style["hex_color"]), - timestamp=datetime.now().isoformat(), - author=Author(name=author, icon_url=author_icon), - footer=Footer( - text=style["footer_text"], - icon_url=style["footer_icon_url"], - ), - fields=[ - spacer(), - *generate_fields( - name='**Deployed Content:**', - value=deployed_content, - inline=False, - ), - spacer(), - *generate_fields( - name='**Content to Review:**', - value=content_to_review, - inline=False, - ), - spacer(), - *generate_fields( - name='**Error:**', - value=error, - inline=False, - ), - spacer(), - Field( - name='**GitHub Action:**', - value=f'[View here]({action_url})', - inline=False, - ), - spacer(), - ], - )], + messages=[ + WebhookMessage( + sections=[ + build_header_section( + status_header=status_header, + course_name=course_name, + course_url=course_url, + author=author, + branch=branch, + summary_table=summary_table, + executive_summary=_build_canvas_summary(data), + ), + *build_bullet_sections("Content to Review:", review_items), + *build_bullet_sections("Remaining Content:", remaining_items), + *build_text_section("Error:", error), + f"Run: {action_url}", + ], + continuation_title="Canvas details (continued)", + ) + ], ) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index 5054425..dad4b58 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -1,7 +1,12 @@ -from datetime import datetime - -from notifications.resources import Notification, Embed, Field, Author, Footer -from notifications.formatting.formatting_utils import spacer, generate_fields, truncate_error, get_course_style, hex_to_int +from notifications.formatting.formatting_utils import get_course_style, truncate_error +from notifications.formatting.plain_text_utils import ( + build_bullet_sections, + build_header_section, + build_text_section, + format_name_items, + pluralize, +) +from notifications.resources import Notification, WebhookMessage def has_content(data) -> bool: @@ -16,64 +21,55 @@ def requires_review(data) -> bool: return bool(data["failed_images"] or data["error"]) -def format_notification(data, course_id, author, author_icon, branch, action_url) -> Notification: - style = get_course_style("docker") +def _build_docker_summary(data: dict) -> str: + updated_count = len(data["updated_images"]) + failed_count = len(data["failed_images"]) + error_count = 1 if data["error"] else 0 - updated_images = ( - '\n'.join(f'- {image}' - for image in data['updated_images'])) \ - if data['updated_images'] \ - else '*No updated images*' + summary = ( + f"{updated_count} {pluralize(updated_count, 'image')} updated, " + f"{failed_count} {pluralize(failed_count, 'image')} failed." + ) + if error_count: + summary += f" {error_count} {pluralize(error_count, 'error')} reported." + return summary - failed_images = ( - '\n'.join(f'- {image}' - for image in data['failed_images'])) \ - if data['failed_images'] \ - else '*No items to review*' - error = data["error"] if data['error'] else '*No errors*' - if error != '*No errors*': - error = truncate_error(error) +def format_notification( + data, + course_id, + course_name, + course_url, + author, + author_icon, + branch, + action_url, +) -> Notification: + style = get_course_style("docker") + + status_header = "Docker update needs attention" if requires_review(data) else "Docker update posted" + error = truncate_error(data["error"]) if data["error"] else None return Notification( username=style["username"], avatar_url=style["avatar_url"], - embeds=[Embed( - title=style["title_template"].format(course_id=course_id), - description=f'**`{branch}`**', - color=hex_to_int(style["hex_color"]), - timestamp=datetime.now().isoformat(), - author=Author(name=author, icon_url=author_icon), - footer=Footer( - text=style["footer_text"], - icon_url=style["footer_icon_url"], - ), - fields=[ - spacer(), - *generate_fields( - name='**Updated Image(s):**', - value=updated_images, - inline=True, - ), - spacer(), - *generate_fields( - name='**Failed Image(s):**', - value=failed_images, - inline=True, - ), - spacer(), - *generate_fields( - name='**Error:**', - value=error, - inline=False, - ), - spacer(), - Field( - name='**GitHub Action:**', - value=f'[View here]({action_url})', - inline=False, - ), - spacer(), - ], - )], + messages=[ + WebhookMessage( + sections=[ + build_header_section( + status_header=status_header, + course_name=course_name, + course_url=course_url, + author=author, + branch=branch, + executive_summary=_build_docker_summary(data), + ), + *build_bullet_sections("Updated Images:", format_name_items(data["updated_images"])), + *build_bullet_sections("Failed Images:", format_name_items(data["failed_images"])), + *build_text_section("Error:", error), + f"Run: {action_url}", + ], + continuation_title="Docker details (continued)", + ) + ], ) diff --git a/notifications/formatting/plain_text_utils.py b/notifications/formatting/plain_text_utils.py new file mode 100644 index 0000000..20a0405 --- /dev/null +++ b/notifications/formatting/plain_text_utils.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from collections.abc import Iterable + + +MAX_SECTION_CHARS = 1100 + + +def pluralize(count: int, singular: str, plural: str | None = None) -> str: + if count == 1: + return singular + return plural or f"{singular}s" + + +def summarize_names(names: Iterable[str], limit: int = 3) -> str: + seen: set[str] = set() + ordered = [] + for name in names: + if name and name not in seen: + ordered.append(name) + seen.add(name) + + if not ordered: + return "" + + shown = ordered[:limit] + remainder = len(ordered) - len(shown) + summary = ", ".join(shown) + if remainder > 0: + summary += f" (+{remainder} more)" + return summary + + +def render_summary_table(rows: list[tuple[str, int]]) -> str: + type_width = max(len("Type"), *(len(label) for label, _ in rows)) + count_width = max(len("Count"), *(len(str(count)) for _, count in rows)) + + lines = [ + f"{'Type':<{type_width}} | {'Count':>{count_width}}", + f"{'-' * type_width}-+-{'-' * count_width}", + ] + lines.extend(f"{label:<{type_width}} | {count:>{count_width}}" for label, count in rows) + return "```\n" + "\n".join(lines) + "\n```" + + +def build_header_section( + status_header: str, + course_name: str, + course_url: str, + author: str, + branch: str, + executive_summary: str, + summary_table: str | None = None, +) -> str: + lines = [ + status_header, + f"Course: {course_name} - {course_url}", + f"By: {author}", + f"Branch: {branch}", + ] + + if summary_table: + lines.extend(["", summary_table]) + + lines.extend(["", f"Executive Summary: {executive_summary}"]) + return "\n".join(lines) + + +def build_bullet_sections(title: str, items: list[str], max_chars: int = MAX_SECTION_CHARS) -> list[str]: + if not items: + return [] + + sections = [] + current_lines: list[str] = [] + current_length = len(title) + 1 + + for item in items: + line = f"- {item}" + needed = len(line) + (1 if current_lines else 0) + if current_lines and current_length + needed > max_chars: + sections.append(f"{title}\n" + "\n".join(current_lines)) + current_lines = [line] + current_length = len(title) + 1 + len(line) + continue + + current_lines.append(line) + current_length += needed + + if current_lines: + sections.append(f"{title}\n" + "\n".join(current_lines)) + + return sections + + +def build_text_section(title: str, body: str | None) -> list[str]: + if not body: + return [] + return [f"{title}\n{body}"] + + +def dedupe_remaining_content( + deployed_content: list[tuple[str, str, str | None]], + review_items: list[tuple[str, str]], +) -> list[tuple[str, str | None]]: + review_urls = {url for _, url in review_items if url} + review_labels = {label for label, _ in review_items} + + seen_urls: set[str] = set() + seen_labels: set[str] = set() + remaining = [] + + for _, label, url in deployed_content: + if url and url in review_urls: + continue + if label in review_labels: + continue + if url and url in seen_urls: + continue + if label in seen_labels: + continue + + if url: + seen_urls.add(url) + seen_labels.add(label) + remaining.append((label, url)) + + return remaining + + +def format_link_items(items: Iterable[tuple[str, str | None]]) -> list[str]: + formatted = [] + for label, url in items: + if url: + formatted.append(f"{label}: {url}") + else: + formatted.append(label) + return formatted + + +def format_name_items(items: Iterable[str]) -> list[str]: + return [item for item in items if item] diff --git a/notifications/formatting/pypi_format.py b/notifications/formatting/pypi_format.py index adf7c5b..a40e149 100644 --- a/notifications/formatting/pypi_format.py +++ b/notifications/formatting/pypi_format.py @@ -1,6 +1,6 @@ from datetime import datetime -from notifications.resources import Notification, Embed, Field, Author, Footer +from notifications.resources import Notification, Embed, Field, Author, Footer, WebhookMessage from notifications.formatting.formatting_utils import spacer, get_pypi_style, hex_to_int @@ -14,24 +14,30 @@ def format_notification(ntype, author, author_icon, action_url, success, version return Notification( username=style["username"], - embeds=[Embed( - title=style["title"], - description=description, - color=hex_to_int(style["hex_color"]), - timestamp=datetime.now().isoformat(), - author=Author(name=author, icon_url=author_icon), - footer=Footer( - text=style["footer_text"], - icon_url=style["footer_icon_url"], - ), - fields=[ - spacer(), - Field( - name="GitHub Action:", - value=f"[View Here]({action_url})", - inline=False, - ), - spacer(), - ], - )], + messages=[ + WebhookMessage( + embeds=[ + Embed( + title=style["title"], + description=description, + color=hex_to_int(style["hex_color"]), + timestamp=datetime.now().isoformat(), + author=Author(name=author, icon_url=author_icon), + footer=Footer( + text=style["footer_text"], + icon_url=style["footer_icon_url"], + ), + fields=[ + spacer(), + Field( + name="GitHub Action:", + value=f"[View Here]({action_url})", + inline=False, + ), + spacer(), + ], + ) + ], + ) + ], ) diff --git a/notifications/resources.py b/notifications/resources.py index cdf70a9..80e6a9f 100644 --- a/notifications/resources.py +++ b/notifications/resources.py @@ -31,9 +31,16 @@ class Embed: footer: Footer | None = None +@dataclass +class WebhookMessage: + content: str | None = None + embeds: list[Embed] = field(default_factory=list) + sections: list[str] = field(default_factory=list) + continuation_title: str | None = None + + @dataclass class Notification: username: str - embeds: list[Embed] + messages: list[WebhookMessage] avatar_url: str | None = None - content: str | None = None diff --git a/notifications/send_course.py b/notifications/send_course.py index 92a29e8..11a15d0 100644 --- a/notifications/send_course.py +++ b/notifications/send_course.py @@ -12,7 +12,7 @@ } -def main(ntype, payload, course_id, author, author_icon, branch_name, action_url, cicd_role_id): +def main(ntype, payload, course_id, course_name, course_url, author, branch_name, action_url, cicd_role_id): webhook_url = os.getenv("DISCORD_WEBHOOK_URL") if not webhook_url: raise EnvironmentError("DISCORD_WEBHOOK_URL environment variable is not set.") @@ -32,14 +32,16 @@ def main(ntype, payload, course_id, author, author_icon, branch_name, action_url notification = format_notification( data=data, course_id=course_id, + course_name=course_name, + course_url=course_url, author=author, - author_icon=author_icon, + author_icon="", branch=branch_name, action_url=action_url, ) - if requires_review(data) and cicd_role_id: - notification.content = f"<@&{cicd_role_id}>" + if requires_review(data) and cicd_role_id and notification.messages: + notification.messages[0].content = f"<@&{cicd_role_id}>" send_notification(webhook_url, notification) @@ -49,12 +51,23 @@ def main(ntype, payload, course_id, author, author_icon, branch_name, action_url parser.add_argument("--type", required=True, choices=["canvas", "docker"], help="Type of notification") parser.add_argument("--payload", required=True, help="Path to the payload JSON file") parser.add_argument("--course-id", required=True, help="Course ID") + parser.add_argument("--course-name", required=True, help="Course name") + parser.add_argument("--course-url", required=True, help="Course URL") parser.add_argument("--author", required=True, help="Name of the author") - parser.add_argument("--author-icon", required=True, help="URL of the author's icon") parser.add_argument("--branch", required=True, help="Branch name") parser.add_argument("--action-url", required=True, help="URL to the GHA") parser.add_argument("--cicd-id", nargs='?', const=None, default=None, help="CI/CD Role ID") args = parser.parse_args() - main(args.type, args.payload, args.course_id, args.author, args.author_icon, args.branch, args.action_url, args.cicd_id) + main( + args.type, + args.payload, + args.course_id, + args.course_name, + args.course_url, + args.author, + args.branch, + args.action_url, + args.cicd_id, + ) diff --git a/notifications/send_notification.py b/notifications/send_notification.py index 174ee99..678c950 100644 --- a/notifications/send_notification.py +++ b/notifications/send_notification.py @@ -1,7 +1,8 @@ from discord_webhook import DiscordWebhook, DiscordEmbed -from .resources import Embed, Notification +from .resources import Embed, Notification, WebhookMessage +MAX_CONTENT_CHARS = 2000 MAX_EMBED_CHARS = 5900 # 100-char safety margin below Discord's 6000 @@ -90,66 +91,182 @@ def _chunk_embed(embed: Embed, max_chars: int = MAX_EMBED_CHARS) -> list[Embed]: return chunks -def send_notification(webhook_url: str, notification: Notification): - # Chunk all embeds - all_chunks = [] - for embed_data in notification.embeds: - all_chunks.extend(_chunk_embed(embed_data)) - - for i, embed_data in enumerate(all_chunks): - is_first = (i == 0) - - webhook = DiscordWebhook( - url=webhook_url, - username=notification.username, - avatar_url=notification.avatar_url, - content=notification.content if is_first else None, +def _split_plain_text_content(content: str, max_chars: int) -> list[str]: + if len(content) <= max_chars: + return [content] + + paragraphs = content.split("\n\n") + chunks = [] + current_parts: list[str] = [] + current_length = 0 + + for paragraph in paragraphs: + needed = len(paragraph) + (2 if current_parts else 0) + if current_parts and current_length + needed > max_chars: + chunks.append("\n\n".join(current_parts)) + current_parts = [paragraph] + current_length = len(paragraph) + continue + current_parts.append(paragraph) + current_length += needed + + if current_parts: + chunks.append("\n\n".join(current_parts)) + + return chunks + + +def _split_large_section(section: str, max_chars: int) -> list[str]: + if len(section) <= max_chars: + return [section] + + lines = section.splitlines() + if len(lines) <= 1: + return _split_plain_text_content(section, max_chars) + + heading = lines[0] + body_lines = lines[1:] + sections = [] + current_lines: list[str] = [] + current_length = len(heading) + 1 + + for line in body_lines: + needed = len(line) + (1 if current_lines else 0) + if current_lines and current_length + needed > max_chars: + sections.append(heading + "\n" + "\n".join(current_lines)) + current_lines = [line] + current_length = len(heading) + 1 + len(line) + continue + current_lines.append(line) + current_length += needed + + if current_lines: + sections.append(heading + "\n" + "\n".join(current_lines)) + + return sections + + +def _chunk_plain_text_message(message: WebhookMessage, max_chars: int = MAX_CONTENT_CHARS) -> list[str]: + if not message.sections: + return _split_plain_text_content(message.content or "", max_chars) if message.content else [] + + first_prefix = message.content + continuation_prefix = message.continuation_title + chunked_sections = [] + + for section in message.sections: + prefix_length = len(continuation_prefix or "") + 2 if continuation_prefix else 0 + chunked_sections.extend(_split_large_section(section, max_chars - prefix_length)) + + chunks = [] + current_sections: list[str] = [] + current_prefix = first_prefix + current_length = len(current_prefix) + 2 if current_prefix else 0 + + for section in chunked_sections: + needed = len(section) + (2 if current_sections or current_prefix else 0) + if current_sections and current_length + needed > max_chars: + chunks.append("\n\n".join(([current_prefix] if current_prefix else []) + current_sections)) + current_sections = [] + current_prefix = continuation_prefix + current_length = len(current_prefix) + 2 if current_prefix else 0 + + current_sections.append(section) + current_length += len(section) + (2 if current_sections[:-1] or current_prefix else 0) + + if current_sections: + chunks.append("\n\n".join(([current_prefix] if current_prefix else []) + current_sections)) + + return chunks + + +def _build_discord_embed(embed_data: Embed) -> DiscordEmbed: + embed = DiscordEmbed( + title=embed_data.title, + description=embed_data.description, + color=embed_data.color, + timestamp=embed_data.timestamp, + ) + + if embed_data.author: + embed.set_author( + name=embed_data.author.name, + icon_url=embed_data.author.icon_url, ) - embed = DiscordEmbed( - title=embed_data.title, - description=embed_data.description, - color=embed_data.color, - timestamp=embed_data.timestamp, + if embed_data.footer: + embed.set_footer( + text=embed_data.footer.text, + icon_url=embed_data.footer.icon_url, ) - if embed_data.author: - embed.set_author( - name=embed_data.author.name, - icon_url=embed_data.author.icon_url, - ) + for field in embed_data.fields: + field_name = field.name or "\u200b" + field_value = field.value or "\u200b" - if embed_data.footer: - embed.set_footer( - text=embed_data.footer.text, - icon_url=embed_data.footer.icon_url, - ) + if not field_name.strip(): + field_name = "\u200b" + if not field_value.strip(): + field_value = "\u200b" - for field in embed_data.fields: - field_name = field.name - field_value = field.value + embed.add_embed_field( + name=field_name, + value=field_value, + inline=field.inline, + ) - if not field_name or not field_name.strip(): - field_name = "\u200b" - if not field_value or not field_value.strip(): - field_value = "\u200b" + return embed - embed.add_embed_field( - name=field_name, - value=field_value, - inline=field.inline, - ) - webhook.add_embed(embed) +def _execute_webhook(webhook_url: str, notification: Notification, content: str | None = None, embeds: list[Embed] | None = None): + webhook = DiscordWebhook( + url=webhook_url, + username=notification.username, + avatar_url=notification.avatar_url, + content=content, + ) + + for embed_data in embeds or []: + webhook.add_embed(_build_discord_embed(embed_data)) + return webhook.execute() + + +def send_notification(webhook_url: str, notification: Notification): + outbound_messages: list[tuple[str | None, list[Embed]]] = [] + + for message in notification.messages: + if message.sections: + for chunk_content in _chunk_plain_text_message(message): + outbound_messages.append((chunk_content, [])) + continue + + if message.embeds: + embed_chunks = [] + for embed_data in message.embeds: + embed_chunks.extend(_chunk_embed(embed_data)) + + for index, embed_data in enumerate(embed_chunks): + outbound_messages.append((message.content if index == 0 else None, [embed_data])) + continue + + if message.content: + for chunk_content in _split_plain_text_content(message.content, MAX_CONTENT_CHARS): + outbound_messages.append((chunk_content, [])) + + for index, (content, embeds) in enumerate(outbound_messages, start=1): try: - response = webhook.execute() + response = _execute_webhook( + webhook_url=webhook_url, + notification=notification, + content=content, + embeds=embeds, + ) except Exception as e: - print(f"\u274c Error sending chunk {i + 1}/{len(all_chunks)}: {e}") + print(f"\u274c Error sending chunk {index}/{len(outbound_messages)}: {e}") continue - # TODO: Check for Discord's specific character-limit error code if response.status_code >= 400: - print(f"\u274c Discord returned status {response.status_code} on chunk {i + 1}/{len(all_chunks)}: {response.text}") + print(f"\u274c Discord returned status {response.status_code} on chunk {index}/{len(outbound_messages)}: {response.text}") else: - print(f"\u2705 Sent chunk {i + 1}/{len(all_chunks)} successfully.") + print(f"\u2705 Sent chunk {index}/{len(outbound_messages)} successfully.") diff --git a/notifications/send_pypi.py b/notifications/send_pypi.py index 98b5e60..d8d5fb4 100644 --- a/notifications/send_pypi.py +++ b/notifications/send_pypi.py @@ -19,8 +19,8 @@ def main(ntype, author, author_icon, action_url, success=None, version=None, cic version=version, ) - if not success and cicd_role_id: - notification.content = f"<@&{cicd_role_id}>" + if not success and cicd_role_id and notification.messages: + notification.messages[0].content = f"<@&{cicd_role_id}>" send_notification(webhook_url, notification) diff --git a/tests/test_course_text_formatters.py b/tests/test_course_text_formatters.py new file mode 100644 index 0000000..2b41d1a --- /dev/null +++ b/tests/test_course_text_formatters.py @@ -0,0 +1,138 @@ +from notifications.formatting.canvas_format import format_notification as format_canvas_notification +from notifications.formatting.docker_format import format_notification as format_docker_notification + + +def test_canvas_notification_formats_plain_text_sections_with_summary_table_and_links(): + notification = format_canvas_notification( + data={ + "deployed_content": [ + ("Page", "Week 12 Overview", "https://courses.example/week-12"), + ("Page", "Lab 8 Instructions", "https://courses.example/lab-8"), + ("Assignment", "Project Milestone", "https://courses.example/project"), + ("Page", "Needs Review", "https://courses.example/review-me"), + ], + "content_to_review": [ + ("Needs Review", "https://courses.example/review-me"), + ("Professor Approval", "https://courses.example/professor"), + ], + "error": "", + }, + course_id="235", + course_name="CS 235 Spring 2026", + course_url="https://courses.example/cs235", + author="robbykapua", + author_icon="", + branch="main", + action_url="https://github.com/testkapua/testni-repo/actions/runs/123", + ) + + message = notification.messages[0] + text = "\n\n".join(message.sections) + + assert "Course: CS 235 Spring 2026 - https://courses.example/cs235" in text + assert "By: robbykapua" in text + assert "Branch: main" in text + assert "Type" in text + assert "Deployed" in text + assert "Review" in text + assert "Errors" in text + assert "Executive Summary:" in text + assert "Week 12 Overview" in text + assert "Lab 8 Instructions" in text + assert "Project Milestone" in text + assert "(+1 more)" in text + assert "Content to Review:" in text + assert "- Needs Review: https://courses.example/review-me" in text + assert "- Professor Approval: https://courses.example/professor" in text + assert "Remaining Content:" in text + assert text.count("https://courses.example/review-me") == 1 + assert "Run: https://github.com/testkapua/testni-repo/actions/runs/123" in text + + +def test_canvas_notification_includes_truncated_error_section_when_present(): + notification = format_canvas_notification( + data={ + "deployed_content": [], + "content_to_review": [], + "error": "\n".join( + [ + "prefix noise", + "Traceback (most recent call last):", + ' File "runner.py", line 1, in ', + " raise RuntimeError('boom')", + "RuntimeError: boom", + ] + ), + }, + course_id="235", + course_name="CS 235 Spring 2026", + course_url="https://courses.example/cs235", + author="robbykapua", + author_icon="", + branch="main", + action_url="https://github.com/testkapua/testni-repo/actions/runs/123", + ) + + text = "\n\n".join(notification.messages[0].sections) + + assert "Error:" in text + assert "Traceback (most recent call last):" in text + assert "RuntimeError: boom" in text + + +def test_docker_notification_formats_plain_text_sections_without_links_or_canvas_table(): + notification = format_docker_notification( + data={ + "updated_images": ["lab-1", "lab-2"], + "failed_images": ["project-base"], + "error": "", + }, + course_id="235", + course_name="CS 235 Spring 2026", + course_url="https://courses.example/cs235", + author="robbykapua", + author_icon="", + branch="main", + action_url="https://github.com/testkapua/testni-repo/actions/runs/123", + ) + + text = "\n\n".join(notification.messages[0].sections) + + assert "Course: CS 235 Spring 2026 - https://courses.example/cs235" in text + assert "Updated Images:" in text + assert "- lab-1" in text + assert "- lab-2" in text + assert "Failed Images:" in text + assert "- project-base" in text + assert "Type" not in text + assert "https://courses.example/lab-1" not in text + + +def test_docker_notification_includes_error_section_for_failures(): + notification = format_docker_notification( + data={ + "updated_images": [], + "failed_images": ["project-base"], + "error": "\n".join( + [ + "build logs", + "Traceback (most recent call last):", + ' File "docker.py", line 10, in ', + " raise ValueError('bad image')", + "ValueError: bad image", + ] + ), + }, + course_id="235", + course_name="CS 235 Spring 2026", + course_url="https://courses.example/cs235", + author="robbykapua", + author_icon="", + branch="main", + action_url="https://github.com/testkapua/testni-repo/actions/runs/123", + ) + + text = "\n\n".join(notification.messages[0].sections) + + assert "Error:" in text + assert "ValueError: bad image" in text diff --git a/tests/test_chunking.py b/tests/test_embed_chunking.py similarity index 94% rename from tests/test_chunking.py rename to tests/test_embed_chunking.py index 63354e1..85e7806 100644 --- a/tests/test_chunking.py +++ b/tests/test_embed_chunking.py @@ -1,7 +1,5 @@ -import pytest - -from notifications.resources import Embed, Field, Author, Footer -from notifications.send_notification import _calc_embed_size, _chunk_embed, MAX_EMBED_CHARS +from notifications.resources import Author, Embed, Field, Footer +from notifications.send_notification import MAX_EMBED_CHARS, _calc_embed_size, _chunk_embed class TestCalcEmbedSize: @@ -45,7 +43,6 @@ def test_spacer_fields(self): fields=[Field(name="\u200b", value="\u200b")], timestamp="", ) - # Zero-width spaces are 1 char each assert _calc_embed_size(embed) == 2 @@ -65,7 +62,6 @@ def test_small_embed_passthrough(self): assert chunks[0] is embed def test_large_embed_splits(self): - # Create fields that will exceed the limit fields = [Field(name=f"field-{i}", value="x" * 500) for i in range(20)] embed = Embed( title="Big Embed", @@ -187,7 +183,6 @@ def test_no_footer_case(self): assert chunk.footer is None def test_typical_pypi_embed_stays_single(self): - # A typical pypi notification is small enough to fit in one embed fields = [ Field(name="Package", value="my-package"), Field(name="Version", value="1.2.3"), diff --git a/tests/test_plain_text_chunking.py b/tests/test_plain_text_chunking.py new file mode 100644 index 0000000..41c6909 --- /dev/null +++ b/tests/test_plain_text_chunking.py @@ -0,0 +1,64 @@ +from notifications.resources import WebhookMessage +from notifications.send_notification import _chunk_plain_text_message + + +def test_chunk_plain_text_message_preserves_prefix_only_on_first_chunk(): + message = WebhookMessage( + content="<@&123456>", + sections=[ + "\n".join( + [ + "Canvas update needs review", + "Course: CS 235 - https://courses.example/cs235", + "By: robbykapua", + "Branch: main", + "", + "Executive Summary: 2 items deployed, 3 items need review, 1 error.", + ] + ), + "\n".join( + [ + "Content to Review:", + "- Week 12 Overview: https://courses.example/week-12", + "- Lab 8 Instructions: https://courses.example/lab-8", + ] + ), + "\n".join( + [ + "Remaining Content:", + "- Project Milestone: https://courses.example/project", + "- Syllabus Refresh: https://courses.example/syllabus", + ] + ), + ], + continuation_title="Canvas details (continued)", + ) + + chunks = _chunk_plain_text_message(message, max_chars=320) + + assert len(chunks) == 2 + assert chunks[0].startswith("<@&123456>\n\nCanvas update needs review") + assert "Content to Review:" in chunks[0] + assert chunks[1].startswith("Canvas details (continued)\n\nRemaining Content:") + assert "<@&123456>" not in chunks[1] + + +def test_chunk_plain_text_message_keeps_chunks_under_limit_and_in_order(): + message = WebhookMessage( + sections=[ + "Canvas update posted\nCourse: CS 235 - https://courses.example/cs235", + "Summary:\n- Deployed: 12\n- Review: 0\n- Errors: 0", + "Remaining Content:\n" + "\n".join( + f"- Item {index}: https://courses.example/item-{index}" for index in range(1, 18) + ), + ], + continuation_title="Canvas details (continued)", + ) + + chunks = _chunk_plain_text_message(message, max_chars=180) + + assert len(chunks) >= 2 + assert all(len(chunk) <= 180 for chunk in chunks) + assert "Canvas update posted" in chunks[0] + assert "Summary:" in chunks[0] + assert "Item 1" in chunks[1] diff --git a/tests/test_workflow_wiring.py b/tests/test_workflow_wiring.py new file mode 100644 index 0000000..e555799 --- /dev/null +++ b/tests/test_workflow_wiring.py @@ -0,0 +1,26 @@ +from pathlib import Path + + +def test_send_course_notification_action_wires_course_metadata_and_short_branch(): + text = Path(".github/actions/send-course-notification/action.yml").read_text() + + assert "course-name:" in text + assert "course-url:" in text + assert '--course-name "${{ inputs.course-name }}"' in text + assert '--course-url "${{ inputs.course-url }}"' in text + assert '--branch "${{ github.ref_name }}"' in text + + +def test_course_workflows_expose_and_forward_course_metadata_inputs(): + canvas_workflow = Path(".github/workflows/mdxcanvas_automation.yaml").read_text() + docker_workflow = Path(".github/workflows/docker_automation.yaml").read_text() + + assert "course_name:" in canvas_workflow + assert "course_url:" in canvas_workflow + assert "course-name: ${{ inputs.course_name }}" in canvas_workflow + assert "course-url: ${{ inputs.course_url }}" in canvas_workflow + + assert "course_name:" in docker_workflow + assert "course_url:" in docker_workflow + assert "course-name: ${{ inputs.course_name }}" in docker_workflow + assert "course-url: ${{ inputs.course_url }}" in docker_workflow From 556616a0672461e6fdcfa35fd1bdb5ad486e2058 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 12:46:42 -0600 Subject: [PATCH 29/32] Fix reusable workflow checkout refs --- .github/workflows/docker_automation.yaml | 3 ++- .github/workflows/mdxcanvas_automation.yaml | 4 ++-- tests/test_workflow_wiring.py | 10 ++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index 5a6c2ba..3328a7a 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -41,6 +41,7 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils + ref: ${{ github.workflow_sha }} path: utils - name: Install notification dependencies @@ -84,7 +85,7 @@ jobs: - name: Send course notification if: always() - uses: ./.github/actions/send-course-notification + uses: ./utils/.github/actions/send-course-notification with: notification-type: docker output-type: Docker diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index b853d03..e9d8a70 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils - ref: canvas-notification-updates + ref: ${{ github.workflow_sha }} path: utils - name: Install MDXCanvas @@ -101,7 +101,7 @@ jobs: cat "$LOGS_DIR/mdxcanvas.stderr.log" - name: Send course notification - uses: BYU-CS-Course-Ops/utils/.github/actions/send-course-notification@canvas-notification-updates + uses: ./utils/.github/actions/send-course-notification with: notification-type: canvas output-type: MDXCanvas diff --git a/tests/test_workflow_wiring.py b/tests/test_workflow_wiring.py index e555799..059c90a 100644 --- a/tests/test_workflow_wiring.py +++ b/tests/test_workflow_wiring.py @@ -24,3 +24,13 @@ def test_course_workflows_expose_and_forward_course_metadata_inputs(): assert "course_url:" in docker_workflow assert "course-name: ${{ inputs.course_name }}" in docker_workflow assert "course-url: ${{ inputs.course_url }}" in docker_workflow + + +def test_course_workflows_checkout_utils_at_called_workflow_sha_and_use_local_utils_action(): + canvas_workflow = Path(".github/workflows/mdxcanvas_automation.yaml").read_text() + docker_workflow = Path(".github/workflows/docker_automation.yaml").read_text() + + assert "ref: ${{ github.workflow_sha }}" in canvas_workflow + assert "ref: ${{ github.workflow_sha }}" in docker_workflow + assert "uses: ./utils/.github/actions/send-course-notification" in canvas_workflow + assert "uses: ./utils/.github/actions/send-course-notification" in docker_workflow From 1508b632181b70e2f91263a07ef7d72b85a6ccb7 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 12:49:00 -0600 Subject: [PATCH 30/32] Add explicit utils_ref for reusable workflow tests --- .github/workflows/docker_automation.yaml | 6 +++++- .github/workflows/mdxcanvas_automation.yaml | 6 +++++- tests/test_workflow_wiring.py | 10 +++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index 3328a7a..4957e0f 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -12,6 +12,10 @@ on: course_url: required: true type: string + utils_ref: + required: false + default: main + type: string secrets: discord_role: required: true @@ -41,7 +45,7 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils - ref: ${{ github.workflow_sha }} + ref: ${{ inputs.utils_ref }} path: utils - name: Install notification dependencies diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index e9d8a70..f458af8 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -12,6 +12,10 @@ on: course_url: required: true type: string + utils_ref: + required: false + default: main + type: string mdxcanvas_version: required: true type: string @@ -59,7 +63,7 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils - ref: ${{ github.workflow_sha }} + ref: ${{ inputs.utils_ref }} path: utils - name: Install MDXCanvas diff --git a/tests/test_workflow_wiring.py b/tests/test_workflow_wiring.py index 059c90a..aa58587 100644 --- a/tests/test_workflow_wiring.py +++ b/tests/test_workflow_wiring.py @@ -26,11 +26,15 @@ def test_course_workflows_expose_and_forward_course_metadata_inputs(): assert "course-url: ${{ inputs.course_url }}" in docker_workflow -def test_course_workflows_checkout_utils_at_called_workflow_sha_and_use_local_utils_action(): +def test_course_workflows_accept_utils_ref_and_use_local_utils_action(): canvas_workflow = Path(".github/workflows/mdxcanvas_automation.yaml").read_text() docker_workflow = Path(".github/workflows/docker_automation.yaml").read_text() - assert "ref: ${{ github.workflow_sha }}" in canvas_workflow - assert "ref: ${{ github.workflow_sha }}" in docker_workflow + assert "utils_ref:" in canvas_workflow + assert "utils_ref:" in docker_workflow + assert "default: main" in canvas_workflow + assert "default: main" in docker_workflow + assert "ref: ${{ inputs.utils_ref }}" in canvas_workflow + assert "ref: ${{ inputs.utils_ref }}" in docker_workflow assert "uses: ./utils/.github/actions/send-course-notification" in canvas_workflow assert "uses: ./utils/.github/actions/send-course-notification" in docker_workflow From a0b4b37b7d33bb8195e4a9eb3e4a8467495b89d0 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 12:52:20 -0600 Subject: [PATCH 31/32] Install markdowndata for course notifications --- .github/workflows/docker_automation.yaml | 2 +- .github/workflows/mdxcanvas_automation.yaml | 2 +- tests/test_workflow_wiring.py | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index 4957e0f..07add43 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -50,7 +50,7 @@ jobs: - name: Install notification dependencies shell: bash - run: pip install discord-webhook ${{ inputs.extra-packages }} + run: pip install discord-webhook markdowndata ${{ inputs.extra-packages }} - name: Get changed files run: | diff --git a/.github/workflows/mdxcanvas_automation.yaml b/.github/workflows/mdxcanvas_automation.yaml index f458af8..6260acb 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -71,7 +71,7 @@ jobs: - name: Install notification dependencies shell: bash - run: pip install discord-webhook ${{ inputs.extra-packages }} + run: pip install discord-webhook markdowndata ${{ inputs.extra-packages }} - name: Create logs directory run: mkdir -p "$LOGS_DIR" diff --git a/tests/test_workflow_wiring.py b/tests/test_workflow_wiring.py index aa58587..31f41bf 100644 --- a/tests/test_workflow_wiring.py +++ b/tests/test_workflow_wiring.py @@ -38,3 +38,11 @@ def test_course_workflows_accept_utils_ref_and_use_local_utils_action(): assert "ref: ${{ inputs.utils_ref }}" in docker_workflow assert "uses: ./utils/.github/actions/send-course-notification" in canvas_workflow assert "uses: ./utils/.github/actions/send-course-notification" in docker_workflow + + +def test_course_workflows_install_markdowndata_for_notification_formatting(): + canvas_workflow = Path(".github/workflows/mdxcanvas_automation.yaml").read_text() + docker_workflow = Path(".github/workflows/docker_automation.yaml").read_text() + + assert "pip install discord-webhook markdowndata" in canvas_workflow + assert "pip install discord-webhook markdowndata" in docker_workflow From 0ddf516b47e38a742fe06393963f75d9d2a652cb Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 13:06:55 -0600 Subject: [PATCH 32/32] Polish Discord course notification layout --- notifications/formatting/canvas_format.py | 73 +++++++++------- notifications/formatting/docker_format.py | 60 +++++++++---- notifications/formatting/plain_text_utils.py | 88 +++++++++++--------- notifications/send_notification.py | 5 +- tests/test_course_text_formatters.py | 74 +++++++++------- tests/test_plain_text_chunking.py | 74 +++++++++++++--- 6 files changed, 244 insertions(+), 130 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index 91f990f..ddeb6ee 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -1,12 +1,13 @@ from notifications.formatting.formatting_utils import get_course_style, truncate_error from notifications.formatting.plain_text_utils import ( - build_bullet_sections, - build_header_section, - build_text_section, + build_markdown_list_sections, + build_markdown_text_section, + build_metadata_embed, + build_status_section, dedupe_remaining_content, format_link_items, pluralize, - render_summary_table, + status_color, summarize_names, ) from notifications.resources import Notification, WebhookMessage @@ -29,17 +30,23 @@ def _build_canvas_summary(data: dict) -> str: review_count = len(data["content_to_review"]) error_count = 1 if data["error"] else 0 - summary = ( - f"{deployed_count} {pluralize(deployed_count, 'item')} deployed, " - f"{review_count} {pluralize(review_count, 'item')} need review, " - f"{error_count} {pluralize(error_count, 'error')}." - ) + if review_count: + summary = ( + f"**Action needed:** {review_count} {pluralize(review_count, 'item')} need review. " + f"{deployed_count} {pluralize(deployed_count, 'item')} were published." + ) + elif error_count: + summary = ( + f"**Action needed:** {error_count} {pluralize(error_count, 'error')} was reported. " + f"{deployed_count} {pluralize(deployed_count, 'item')} were published." + ) + else: + summary = f"**Status:** {deployed_count} {pluralize(deployed_count, 'item')} were published." changed_items = summarize_names(content for _, content, _ in data["deployed_content"]) if changed_items: - summary += f" Changed items: {changed_items}." - - return summary + return summary, f"> Updated content: {changed_items}" + return summary, None def format_notification( @@ -56,16 +63,16 @@ def format_notification( deployed_count = len(data["deployed_content"]) review_count = len(data["content_to_review"]) - error_count = 1 if data["error"] else 0 - status_header = "Canvas update needs review" if requires_review(data) else "Canvas update posted" - summary_table = render_summary_table( - [ - ("Deployed", deployed_count), - ("Review", review_count), - ("Errors", error_count), - ] + embed_title = "Canvas Update Needs Review" if requires_review(data) else "Canvas Update Posted" + embed_description = ( + "A publishing error was reported." + if data["error"] + else "Review required before everything is fully published." + if review_count + else "Everything published cleanly." ) + summary_line, highlight = _build_canvas_summary(data) review_items = format_link_items(data["content_to_review"]) remaining_items = format_link_items( @@ -78,20 +85,26 @@ def format_notification( avatar_url=style["avatar_url"], messages=[ WebhookMessage( - sections=[ - build_header_section( - status_header=status_header, + embeds=[ + build_metadata_embed( + title=embed_title, + description=embed_description, course_name=course_name, course_url=course_url, author=author, branch=branch, - summary_table=summary_table, - executive_summary=_build_canvas_summary(data), - ), - *build_bullet_sections("Content to Review:", review_items), - *build_bullet_sections("Remaining Content:", remaining_items), - *build_text_section("Error:", error), - f"Run: {action_url}", + footer_text=style["footer_text"], + footer_icon_url=style["footer_icon_url"], + timestamp="", + color=status_color(has_error=bool(data["error"]), needs_review=requires_review(data)), + ) + ], + sections=[ + build_status_section(summary_line, highlight), + *build_markdown_list_sections("Needs Review", review_items), + *build_markdown_list_sections("Published", remaining_items), + *build_markdown_text_section("Error", error), + f"## Run\n{action_url}", ], continuation_title="Canvas details (continued)", ) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index dad4b58..70448a0 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -1,10 +1,12 @@ from notifications.formatting.formatting_utils import get_course_style, truncate_error from notifications.formatting.plain_text_utils import ( - build_bullet_sections, - build_header_section, - build_text_section, + build_markdown_list_sections, + build_markdown_text_section, + build_metadata_embed, + build_status_section, format_name_items, pluralize, + status_color, ) from notifications.resources import Notification, WebhookMessage @@ -26,13 +28,20 @@ def _build_docker_summary(data: dict) -> str: failed_count = len(data["failed_images"]) error_count = 1 if data["error"] else 0 - summary = ( - f"{updated_count} {pluralize(updated_count, 'image')} updated, " - f"{failed_count} {pluralize(failed_count, 'image')} failed." - ) + if failed_count or error_count: + summary = ( + f"**Status:** {updated_count} {pluralize(updated_count, 'image')} updated. " + f"{failed_count} {pluralize(failed_count, 'image')} failed." + ) + else: + summary = f"**Status:** {updated_count} {pluralize(updated_count, 'image')} updated." + if error_count: summary += f" {error_count} {pluralize(error_count, 'error')} reported." - return summary + + updated_names = ", ".join(format_name_items(data["updated_images"][:3])) + highlight = f"> Updated images: {updated_names}" if updated_names else None + return summary, highlight def format_notification( @@ -47,27 +56,42 @@ def format_notification( ) -> Notification: style = get_course_style("docker") - status_header = "Docker update needs attention" if requires_review(data) else "Docker update posted" + status_title = "Docker Update Needs Attention" if requires_review(data) else "Docker Update Posted" + status_description = ( + "A build or publishing error was reported." + if data["error"] + else "Some images failed and may need follow-up." + if data["failed_images"] + else "Everything published cleanly." + ) error = truncate_error(data["error"]) if data["error"] else None + summary_line, highlight = _build_docker_summary(data) return Notification( username=style["username"], avatar_url=style["avatar_url"], messages=[ WebhookMessage( - sections=[ - build_header_section( - status_header=status_header, + embeds=[ + build_metadata_embed( + title=status_title, + description=status_description, course_name=course_name, course_url=course_url, author=author, branch=branch, - executive_summary=_build_docker_summary(data), - ), - *build_bullet_sections("Updated Images:", format_name_items(data["updated_images"])), - *build_bullet_sections("Failed Images:", format_name_items(data["failed_images"])), - *build_text_section("Error:", error), - f"Run: {action_url}", + footer_text=style["footer_text"], + footer_icon_url=style["footer_icon_url"], + timestamp="", + color=status_color(has_error=bool(data["error"]), needs_review=requires_review(data)), + ) + ], + sections=[ + build_status_section(summary_line, highlight), + *build_markdown_list_sections("Updated Images", format_name_items(data["updated_images"])), + *build_markdown_list_sections("Failed Images", format_name_items(data["failed_images"])), + *build_markdown_text_section("Error", error), + f"## Run\n{action_url}", ], continuation_title="Docker details (continued)", ) diff --git a/notifications/formatting/plain_text_utils.py b/notifications/formatting/plain_text_utils.py index 20a0405..e5ee5da 100644 --- a/notifications/formatting/plain_text_utils.py +++ b/notifications/formatting/plain_text_utils.py @@ -2,8 +2,12 @@ from collections.abc import Iterable +from notifications.resources import Embed, Field, Footer MAX_SECTION_CHARS = 1100 +SUCCESS_COLOR = 0x2E8B57 +REVIEW_COLOR = 0xD97706 +ERROR_COLOR = 0xDC2626 def pluralize(count: int, singular: str, plural: str | None = None) -> str: @@ -31,71 +35,79 @@ def summarize_names(names: Iterable[str], limit: int = 3) -> str: return summary -def render_summary_table(rows: list[tuple[str, int]]) -> str: - type_width = max(len("Type"), *(len(label) for label, _ in rows)) - count_width = max(len("Count"), *(len(str(count)) for _, count in rows)) - - lines = [ - f"{'Type':<{type_width}} | {'Count':>{count_width}}", - f"{'-' * type_width}-+-{'-' * count_width}", - ] - lines.extend(f"{label:<{type_width}} | {count:>{count_width}}" for label, count in rows) - return "```\n" + "\n".join(lines) + "\n```" - - -def build_header_section( - status_header: str, +def build_metadata_embed( + *, + title: str, + description: str, course_name: str, course_url: str, author: str, branch: str, - executive_summary: str, - summary_table: str | None = None, -) -> str: - lines = [ - status_header, - f"Course: {course_name} - {course_url}", - f"By: {author}", - f"Branch: {branch}", - ] - - if summary_table: - lines.extend(["", summary_table]) - - lines.extend(["", f"Executive Summary: {executive_summary}"]) + footer_text: str, + footer_icon_url: str, + timestamp: str, + color: int, +) -> Embed: + return Embed( + title=title, + description=description, + color=color, + fields=[ + Field(name="Course", value=f"{course_name}\n{course_url}", inline=True), + Field(name="By", value=author, inline=True), + Field(name="Branch", value=branch, inline=True), + ], + timestamp=timestamp, + footer=Footer(text=footer_text, icon_url=footer_icon_url), + ) + + +def status_color(*, has_error: bool, needs_review: bool) -> int: + if has_error: + return ERROR_COLOR + if needs_review: + return REVIEW_COLOR + return SUCCESS_COLOR + + +def build_status_section(summary_line: str, highlight: str | None = None) -> str: + lines = ["## Status", summary_line] + if highlight: + lines.append(highlight) return "\n".join(lines) -def build_bullet_sections(title: str, items: list[str], max_chars: int = MAX_SECTION_CHARS) -> list[str]: +def build_markdown_list_sections(title: str, items: list[str], max_chars: int = MAX_SECTION_CHARS) -> list[str]: if not items: return [] sections = [] current_lines: list[str] = [] - current_length = len(title) + 1 + heading = f"## {title}" + current_length = len(heading) + 1 for item in items: line = f"- {item}" needed = len(line) + (1 if current_lines else 0) if current_lines and current_length + needed > max_chars: - sections.append(f"{title}\n" + "\n".join(current_lines)) + sections.append(f"{heading}\n" + "\n".join(current_lines)) current_lines = [line] - current_length = len(title) + 1 + len(line) + current_length = len(heading) + 1 + len(line) continue current_lines.append(line) current_length += needed if current_lines: - sections.append(f"{title}\n" + "\n".join(current_lines)) + sections.append(f"{heading}\n" + "\n".join(current_lines)) return sections -def build_text_section(title: str, body: str | None) -> list[str]: +def build_markdown_text_section(title: str, body: str | None) -> list[str]: if not body: return [] - return [f"{title}\n{body}"] + return [f"## {title}\n{body}"] def dedupe_remaining_content( @@ -131,11 +143,11 @@ def format_link_items(items: Iterable[tuple[str, str | None]]) -> list[str]: formatted = [] for label, url in items: if url: - formatted.append(f"{label}: {url}") + formatted.append(f"**{label}**: {url}") else: - formatted.append(label) + formatted.append(f"**{label}**") return formatted def format_name_items(items: Iterable[str]) -> list[str]: - return [item for item in items if item] + return [f"`{item}`" for item in items if item] diff --git a/notifications/send_notification.py b/notifications/send_notification.py index 678c950..633f15a 100644 --- a/notifications/send_notification.py +++ b/notifications/send_notification.py @@ -237,8 +237,9 @@ def send_notification(webhook_url: str, notification: Notification): for message in notification.messages: if message.sections: - for chunk_content in _chunk_plain_text_message(message): - outbound_messages.append((chunk_content, [])) + plain_text_chunks = _chunk_plain_text_message(message) + for index, chunk_content in enumerate(plain_text_chunks): + outbound_messages.append((chunk_content, message.embeds if index == 0 else [])) continue if message.embeds: diff --git a/tests/test_course_text_formatters.py b/tests/test_course_text_formatters.py index 2b41d1a..1521d8b 100644 --- a/tests/test_course_text_formatters.py +++ b/tests/test_course_text_formatters.py @@ -28,25 +28,28 @@ def test_canvas_notification_formats_plain_text_sections_with_summary_table_and_ message = notification.messages[0] text = "\n\n".join(message.sections) - - assert "Course: CS 235 Spring 2026 - https://courses.example/cs235" in text - assert "By: robbykapua" in text - assert "Branch: main" in text - assert "Type" in text - assert "Deployed" in text - assert "Review" in text - assert "Errors" in text - assert "Executive Summary:" in text - assert "Week 12 Overview" in text - assert "Lab 8 Instructions" in text - assert "Project Milestone" in text - assert "(+1 more)" in text - assert "Content to Review:" in text - assert "- Needs Review: https://courses.example/review-me" in text - assert "- Professor Approval: https://courses.example/professor" in text - assert "Remaining Content:" in text + embed = message.embeds[0] + + assert embed.title == "Canvas Update Needs Review" + assert embed.description == "Review required before everything is fully published." + assert embed.color != 0 + assert [field.name for field in embed.fields] == ["Course", "By", "Branch"] + assert embed.fields[0].value == "CS 235 Spring 2026\nhttps://courses.example/cs235" + assert embed.fields[1].value == "robbykapua" + assert embed.fields[2].value == "main" + assert embed.footer is not None + assert "Canvas" in embed.footer.text + + assert "## Status" in text + assert "**Action needed:** 2 items need review. 4 items were published." in text + assert "> Updated content: Week 12 Overview, Lab 8 Instructions, Project Milestone (+1 more)" in text + assert "## Needs Review" in text + assert "- **Needs Review**: https://courses.example/review-me" in text + assert "- **Professor Approval**: https://courses.example/professor" in text + assert "## Published" in text assert text.count("https://courses.example/review-me") == 1 - assert "Run: https://github.com/testkapua/testni-repo/actions/runs/123" in text + assert "## Run" in text + assert "https://github.com/testkapua/testni-repo/actions/runs/123" in text def test_canvas_notification_includes_truncated_error_section_when_present(): @@ -73,9 +76,11 @@ def test_canvas_notification_includes_truncated_error_section_when_present(): action_url="https://github.com/testkapua/testni-repo/actions/runs/123", ) - text = "\n\n".join(notification.messages[0].sections) + message = notification.messages[0] + text = "\n\n".join(message.sections) - assert "Error:" in text + assert message.embeds[0].title == "Canvas Update Needs Review" + assert "## Error" in text assert "Traceback (most recent call last):" in text assert "RuntimeError: boom" in text @@ -96,14 +101,21 @@ def test_docker_notification_formats_plain_text_sections_without_links_or_canvas action_url="https://github.com/testkapua/testni-repo/actions/runs/123", ) - text = "\n\n".join(notification.messages[0].sections) - - assert "Course: CS 235 Spring 2026 - https://courses.example/cs235" in text - assert "Updated Images:" in text - assert "- lab-1" in text - assert "- lab-2" in text - assert "Failed Images:" in text - assert "- project-base" in text + message = notification.messages[0] + text = "\n\n".join(message.sections) + embed = message.embeds[0] + + assert embed.title == "Docker Update Needs Attention" + assert embed.description == "Some images failed and may need follow-up." + assert [field.name for field in embed.fields] == ["Course", "By", "Branch"] + assert "## Status" in text + assert "**Status:** 2 images updated. 1 image failed." in text + assert "> Updated images: `lab-1`, `lab-2`" in text + assert "## Updated Images" in text + assert "- `lab-1`" in text + assert "- `lab-2`" in text + assert "## Failed Images" in text + assert "- `project-base`" in text assert "Type" not in text assert "https://courses.example/lab-1" not in text @@ -132,7 +144,9 @@ def test_docker_notification_includes_error_section_for_failures(): action_url="https://github.com/testkapua/testni-repo/actions/runs/123", ) - text = "\n\n".join(notification.messages[0].sections) + message = notification.messages[0] + text = "\n\n".join(message.sections) - assert "Error:" in text + assert message.embeds[0].title == "Docker Update Needs Attention" + assert "## Error" in text assert "ValueError: bad image" in text diff --git a/tests/test_plain_text_chunking.py b/tests/test_plain_text_chunking.py index 41c6909..955c24e 100644 --- a/tests/test_plain_text_chunking.py +++ b/tests/test_plain_text_chunking.py @@ -1,31 +1,30 @@ from notifications.resources import WebhookMessage -from notifications.send_notification import _chunk_plain_text_message +from notifications.resources import Embed +from notifications.send_notification import _chunk_plain_text_message, send_notification def test_chunk_plain_text_message_preserves_prefix_only_on_first_chunk(): message = WebhookMessage( content="<@&123456>", + embeds=[], sections=[ "\n".join( [ - "Canvas update needs review", - "Course: CS 235 - https://courses.example/cs235", - "By: robbykapua", - "Branch: main", - "", - "Executive Summary: 2 items deployed, 3 items need review, 1 error.", + "## Status", + "**Action needed:** 2 items need review. 3 items were published.", + "> Updated content: Week 12 Overview, Lab 8 Instructions", ] ), "\n".join( [ - "Content to Review:", + "## Needs Review", "- Week 12 Overview: https://courses.example/week-12", "- Lab 8 Instructions: https://courses.example/lab-8", ] ), "\n".join( [ - "Remaining Content:", + "## Published", "- Project Milestone: https://courses.example/project", "- Syllabus Refresh: https://courses.example/syllabus", ] @@ -37,9 +36,9 @@ def test_chunk_plain_text_message_preserves_prefix_only_on_first_chunk(): chunks = _chunk_plain_text_message(message, max_chars=320) assert len(chunks) == 2 - assert chunks[0].startswith("<@&123456>\n\nCanvas update needs review") - assert "Content to Review:" in chunks[0] - assert chunks[1].startswith("Canvas details (continued)\n\nRemaining Content:") + assert chunks[0].startswith("<@&123456>\n\n## Status") + assert "## Needs Review" in chunks[0] + assert chunks[1].startswith("Canvas details (continued)\n\n## Published") assert "<@&123456>" not in chunks[1] @@ -62,3 +61,54 @@ def test_chunk_plain_text_message_keeps_chunks_under_limit_and_in_order(): assert "Canvas update posted" in chunks[0] assert "Summary:" in chunks[0] assert "Item 1" in chunks[1] + + +def test_send_notification_keeps_embed_on_first_plain_text_chunk(monkeypatch): + delivered = [] + + def fake_execute_webhook(webhook_url, notification, content=None, embeds=None): + delivered.append((content, embeds or [])) + + class Response: + status_code = 200 + text = "ok" + + return Response() + + monkeypatch.setattr("notifications.send_notification._execute_webhook", fake_execute_webhook) + + send_notification( + "https://discord.invalid/webhook", + type( + "NotificationStub", + (), + { + "username": "Canvas Notifications", + "avatar_url": None, + "messages": [ + WebhookMessage( + content="<@&123456>", + embeds=[ + Embed( + title="Canvas Update Posted", + description="Everything published cleanly.", + color=0x00AA55, + fields=[], + timestamp="2026-04-01T00:00:00Z", + ) + ], + sections=[ + "## Status\n**Status:** 5 items were published.", + "## Published\n- Week 12 Overview: https://courses.example/week-12", + ], + continuation_title="Canvas details (continued)", + ) + ], + }, + )(), + ) + + assert len(delivered) == 1 + assert delivered[0][0].startswith("<@&123456>\n\n## Status") + assert len(delivered[0][1]) == 1 + assert delivered[0][1][0].title == "Canvas Update Posted"