From 6ed7a0aa8e57e4145fd50b7e603559f7cc602ac6 Mon Sep 17 00:00:00 2001 From: robbykap Date: Fri, 13 Feb 2026 12:16:07 -0700 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 7b7ccaf1b79b2ee642044d3cd3fb0a85d771eda4 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 12:10:20 -0600 Subject: [PATCH 14/43] 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 15/43] 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 16/43] 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 17/43] 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 18/43] 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 19/43] 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" From 188f9fb8b8b564119d58d2add876add513f5acf4 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 13:49:41 -0600 Subject: [PATCH 20/43] Redesign notification UI as hybrid embed + plain text Embed provides a colored header bar with title and footer. Plain text content uses Discord markdown (### headers, links, code blocks) for clean readability. Color-coded sidebar: green (success), orange (needs review), red (errors). Simplified formatters, removed unused helpers, updated tests. Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/canvas_format.py | 111 ++++++-------- notifications/formatting/docker_format.py | 95 ++++++------ notifications/formatting/formatting_utils.py | 56 ------- notifications/formatting/plain_text_utils.py | 111 -------------- notifications/formatting/pypi_format.py | 22 +-- notifications/send_notification.py | 153 ++++--------------- tests/test_course_text_formatters.py | 114 ++++++-------- tests/test_plain_text_chunking.py | 114 -------------- 8 files changed, 175 insertions(+), 601 deletions(-) delete mode 100644 tests/test_plain_text_chunking.py diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index ddeb6ee..a97de49 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -1,16 +1,9 @@ from notifications.formatting.formatting_utils import get_course_style, truncate_error from notifications.formatting.plain_text_utils import ( - build_markdown_list_sections, - build_markdown_text_section, - build_metadata_embed, - build_status_section, dedupe_remaining_content, - format_link_items, - pluralize, status_color, - summarize_names, ) -from notifications.resources import Notification, WebhookMessage +from notifications.resources import Notification, WebhookMessage, Embed, Footer def has_content(data) -> bool: @@ -25,28 +18,45 @@ def requires_review(data) -> bool: return bool(data["content_to_review"] or data["error"]) -def _build_canvas_summary(data: dict) -> str: +def _build_plain_text(data, course_name, course_url, author, branch, action_url): + sections = [] + + # --- Status --- deployed_count = len(data["deployed_content"]) review_count = len(data["content_to_review"]) - error_count = 1 if data["error"] else 0 - - 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." - ) + + if data["error"]: + sections.append(f"A publishing error was reported. {deployed_count} item(s) deployed.") + elif review_count: + sections.append(f"{review_count} item(s) need review. {deployed_count} item(s) deployed.") else: - summary = f"**Status:** {deployed_count} {pluralize(deployed_count, 'item')} were published." + sections.append(f"{deployed_count} item(s) deployed successfully.") + + # --- Needs Review --- + if data["content_to_review"]: + lines = [f"- [{name}]({link})" for name, link in data["content_to_review"]] + sections.append("### Needs Review\n" + "\n".join(lines)) + + # --- Published (deduped) --- + remaining = dedupe_remaining_content(data["deployed_content"], data["content_to_review"]) + if remaining: + lines = [] + for label, url in remaining: + if url: + lines.append(f"- [{label}]({url})") + else: + lines.append(f"- {label}") + sections.append("### Published\n" + "\n".join(lines)) + + # --- Errors --- + if data["error"]: + truncated = truncate_error(data["error"]) + sections.append(f"### Errors\n{truncated}") + + # --- Metadata footer --- + sections.append(f"-# {author} | `{branch}` | [{course_name}]({course_url}) | [Action Log]({action_url})") - changed_items = summarize_names(content for _, content, _ in data["deployed_content"]) - if changed_items: - return summary, f"> Updated content: {changed_items}" - return summary, None + return "\n\n".join(sections) def format_notification( @@ -61,52 +71,31 @@ def format_notification( ) -> Notification: style = get_course_style("canvas") - deployed_count = len(data["deployed_content"]) - review_count = len(data["content_to_review"]) - - 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) + title = "Canvas Deploy" + if data["error"]: + title = "Canvas Deploy -- Error" + elif data["content_to_review"]: + title = "Canvas Deploy -- Review Needed" - 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 + color = status_color(has_error=bool(data["error"]), needs_review=requires_review(data)) + plain_text = _build_plain_text(data, course_name, course_url, author, branch, action_url) return Notification( username=style["username"], avatar_url=style["avatar_url"], messages=[ WebhookMessage( + content=plain_text, embeds=[ - build_metadata_embed( - title=embed_title, - description=embed_description, - course_name=course_name, - course_url=course_url, - author=author, - branch=branch, - footer_text=style["footer_text"], - footer_icon_url=style["footer_icon_url"], + Embed( + title=f"CS {course_id} | {title}", + description="", + color=color, + fields=[], timestamp="", - color=status_color(has_error=bool(data["error"]), needs_review=requires_review(data)), + footer=Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]), ) ], - 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 70448a0..4063477 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -1,14 +1,6 @@ from notifications.formatting.formatting_utils import get_course_style, truncate_error -from notifications.formatting.plain_text_utils import ( - 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 +from notifications.formatting.plain_text_utils import status_color +from notifications.resources import Notification, WebhookMessage, Embed, Footer def has_content(data) -> bool: @@ -23,25 +15,39 @@ def requires_review(data) -> bool: return bool(data["failed_images"] or data["error"]) -def _build_docker_summary(data: dict) -> str: +def _build_plain_text(data, course_name, course_url, author, branch, action_url): + sections = [] + + # --- Status --- updated_count = len(data["updated_images"]) failed_count = len(data["failed_images"]) - error_count = 1 if data["error"] else 0 - if failed_count or error_count: - summary = ( - f"**Status:** {updated_count} {pluralize(updated_count, 'image')} updated. " - f"{failed_count} {pluralize(failed_count, 'image')} failed." - ) + if data["error"]: + sections.append(f"A build error was reported. {updated_count} image(s) built, {failed_count} failed.") + elif failed_count: + sections.append(f"{updated_count} image(s) built. {failed_count} image(s) failed.") else: - summary = f"**Status:** {updated_count} {pluralize(updated_count, 'image')} updated." + sections.append(f"{updated_count} image(s) built successfully.") + + # --- Built --- + if data["updated_images"]: + lines = [f"- `{image}`" for image in data["updated_images"]] + sections.append("### Built\n" + "\n".join(lines)) + + # --- Failed --- + if data["failed_images"]: + lines = [f"- `{image}`" for image in data["failed_images"]] + sections.append("### Failed\n" + "\n".join(lines)) + + # --- Errors --- + if data["error"]: + truncated = truncate_error(data["error"]) + sections.append(f"### Errors\n{truncated}") - if error_count: - summary += f" {error_count} {pluralize(error_count, 'error')} reported." + # --- Metadata footer --- + sections.append(f"-# {author} | `{branch}` | [{course_name}]({course_url}) | [Action Log]({action_url})") - updated_names = ", ".join(format_name_items(data["updated_images"][:3])) - highlight = f"> Updated images: {updated_names}" if updated_names else None - return summary, highlight + return "\n\n".join(sections) def format_notification( @@ -56,44 +62,31 @@ def format_notification( ) -> Notification: style = get_course_style("docker") - 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) + title = "Docker Build" + if data["error"]: + title = "Docker Build -- Error" + elif data["failed_images"]: + title = "Docker Build -- Failures" + + color = status_color(has_error=bool(data["error"]), needs_review=requires_review(data)) + plain_text = _build_plain_text(data, course_name, course_url, author, branch, action_url) return Notification( username=style["username"], avatar_url=style["avatar_url"], messages=[ WebhookMessage( + content=plain_text, embeds=[ - build_metadata_embed( - title=status_title, - description=status_description, - course_name=course_name, - course_url=course_url, - author=author, - branch=branch, - footer_text=style["footer_text"], - footer_icon_url=style["footer_icon_url"], + Embed( + title=f"CS {course_id} | {title}", + description="", + color=color, + fields=[], timestamp="", - color=status_color(has_error=bool(data["error"]), needs_review=requires_review(data)), + footer=Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]), ) ], - 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/formatting_utils.py b/notifications/formatting/formatting_utils.py index 4c35ab3..b0c3a83 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -1,4 +1,3 @@ -from notifications.resources import Field from markdowndata import load from pathlib import Path @@ -30,61 +29,6 @@ def get_pypi_style(ntype: str) -> dict[str, str]: 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 "\u200b", - value=chunk_value, - inline=inline - ) for i, chunk_value in enumerate(chunks) - ] - - # Original logic for non-code-block values - if 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 "\u200b", - 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```" diff --git a/notifications/formatting/plain_text_utils.py b/notifications/formatting/plain_text_utils.py index e5ee5da..6b5a567 100644 --- a/notifications/formatting/plain_text_utils.py +++ b/notifications/formatting/plain_text_utils.py @@ -1,67 +1,10 @@ from __future__ import annotations -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: - 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 build_metadata_embed( - *, - title: str, - description: str, - course_name: str, - course_url: str, - author: str, - branch: str, - 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 @@ -70,46 +13,6 @@ def status_color(*, has_error: bool, needs_review: bool) -> int: 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_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] = [] - 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"{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(f"{heading}\n" + "\n".join(current_lines)) - - return sections - - -def build_markdown_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]], @@ -137,17 +40,3 @@ def dedupe_remaining_content( 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(f"**{label}**") - return formatted - - -def format_name_items(items: Iterable[str]) -> list[str]: - return [f"`{item}`" for item in items if item] diff --git a/notifications/formatting/pypi_format.py b/notifications/formatting/pypi_format.py index a40e149..e5a68ef 100644 --- a/notifications/formatting/pypi_format.py +++ b/notifications/formatting/pypi_format.py @@ -1,16 +1,14 @@ -from datetime import datetime - -from notifications.resources import Notification, Embed, Field, Author, Footer, WebhookMessage -from notifications.formatting.formatting_utils import spacer, get_pypi_style, hex_to_int +from notifications.resources import Notification, Embed, Author, Footer, WebhookMessage +from notifications.formatting.formatting_utils import 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}`**" + description = f"Updated to version **`{version}`**\n\n-# [Action Log]({action_url})" else: - description = f"An **error occurred** while updating {style['display_name']}." + description = f"An **error occurred** while updating {style['display_name']}.\n\n-# [Action Log]({action_url})" return Notification( username=style["username"], @@ -21,21 +19,13 @@ def format_notification(ntype, author, author_icon, action_url, success, version title=style["title"], description=description, color=hex_to_int(style["hex_color"]), - timestamp=datetime.now().isoformat(), + timestamp="", 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(), - ], + fields=[], ) ], ) diff --git a/notifications/send_notification.py b/notifications/send_notification.py index 633f15a..b503d83 100644 --- a/notifications/send_notification.py +++ b/notifications/send_notification.py @@ -1,13 +1,12 @@ from discord_webhook import DiscordWebhook, DiscordEmbed -from .resources import Embed, Notification, WebhookMessage +from .resources import Embed, Notification MAX_CONTENT_CHARS = 2000 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 "") @@ -22,7 +21,6 @@ def _calc_embed_size(embed: Embed) -> int: 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, @@ -46,11 +44,6 @@ def _build_chunk(original: Embed, fields: list, is_first: bool, continuation_tit 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] @@ -58,14 +51,12 @@ def _chunk_embed(embed: Embed, max_chars: int = MAX_EMBED_CHARS) -> list[Embed]: 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 "") @@ -91,7 +82,7 @@ def _chunk_embed(embed: Embed, max_chars: int = MAX_EMBED_CHARS) -> list[Embed]: return chunks -def _split_plain_text_content(content: str, max_chars: int) -> list[str]: +def _split_content(content: str, max_chars: int = MAX_CONTENT_CHARS) -> list[str]: if len(content) <= max_chars: return [content] @@ -116,76 +107,12 @@ def _split_plain_text_content(content: str, max_chars: int) -> list[str]: 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, + timestamp=embed_data.timestamp or None, ) if embed_data.author: @@ -201,17 +128,9 @@ def _build_discord_embed(embed_data: Embed) -> DiscordEmbed: ) for field in embed_data.fields: - field_name = field.name or "\u200b" - field_value = field.value or "\u200b" - - if not field_name.strip(): - field_name = "\u200b" - if not field_value.strip(): - field_value = "\u200b" - embed.add_embed_field( - name=field_name, - value=field_value, + name=field.name or "\u200b", + value=field.value or "\u200b", inline=field.inline, ) @@ -233,41 +152,29 @@ def _execute_webhook(webhook_url: str, notification: Notification, content: str def send_notification(webhook_url: str, notification: Notification): - outbound_messages: list[tuple[str | None, list[Embed]]] = [] - for message in notification.messages: - if message.sections: - 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: - 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 = _execute_webhook( - webhook_url=webhook_url, - notification=notification, - content=content, - embeds=embeds, - ) - except Exception as e: - print(f"\u274c Error sending chunk {index}/{len(outbound_messages)}: {e}") - continue - - if response.status_code >= 400: - print(f"\u274c Discord returned status {response.status_code} on chunk {index}/{len(outbound_messages)}: {response.text}") - else: - print(f"\u2705 Sent chunk {index}/{len(outbound_messages)} successfully.") + # Split plain text content if it exceeds Discord's limit + content_chunks = _split_content(message.content, MAX_CONTENT_CHARS) if message.content else [None] + + # Chunk embeds if they exceed size limit + embed_chunks = [] + for embed_data in message.embeds: + embed_chunks.extend(_chunk_embed(embed_data)) + + # First chunk gets both content and embeds; subsequent chunks get content only + for index, content in enumerate(content_chunks): + try: + response = _execute_webhook( + webhook_url=webhook_url, + notification=notification, + content=content, + embeds=embed_chunks if index == 0 else [], + ) + except Exception as e: + print(f"Error sending notification: {e}") + continue + + if response.status_code >= 400: + print(f"Discord returned status {response.status_code}: {response.text}") + else: + print("Sent message successfully.") diff --git a/tests/test_course_text_formatters.py b/tests/test_course_text_formatters.py index 1521d8b..96a2139 100644 --- a/tests/test_course_text_formatters.py +++ b/tests/test_course_text_formatters.py @@ -2,7 +2,7 @@ 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(): +def test_canvas_notification_with_review_items(): notification = format_canvas_notification( data={ "deployed_content": [ @@ -23,49 +23,39 @@ def test_canvas_notification_formats_plain_text_sections_with_summary_table_and_ author="robbykapua", author_icon="", branch="main", - action_url="https://github.com/testkapua/testni-repo/actions/runs/123", + action_url="https://github.com/testkapua/testing-repo/actions/runs/123", ) message = notification.messages[0] - text = "\n\n".join(message.sections) embed = message.embeds[0] + text = message.content - 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" + # Embed is the header + assert "Review Needed" in embed.title 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 + # Content is plain 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 + # Review items deduped from published assert text.count("https://courses.example/review-me") == 1 - assert "## Run" in text - assert "https://github.com/testkapua/testni-repo/actions/runs/123" in text + assert "Action Log" in text -def test_canvas_notification_includes_truncated_error_section_when_present(): +def test_canvas_notification_with_error(): 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", - ] - ), + "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", @@ -73,19 +63,16 @@ def test_canvas_notification_includes_truncated_error_section_when_present(): author="robbykapua", author_icon="", branch="main", - action_url="https://github.com/testkapua/testni-repo/actions/runs/123", + action_url="https://github.com/testkapua/testing-repo/actions/runs/123", ) message = notification.messages[0] - text = "\n\n".join(message.sections) + assert "Error" in message.embeds[0].title + assert "### Errors" in message.content + assert "RuntimeError: boom" in message.content - 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 - -def test_docker_notification_formats_plain_text_sections_without_links_or_canvas_table(): +def test_docker_notification_with_failures(): notification = format_docker_notification( data={ "updated_images": ["lab-1", "lab-2"], @@ -98,42 +85,33 @@ def test_docker_notification_formats_plain_text_sections_without_links_or_canvas author="robbykapua", author_icon="", branch="main", - action_url="https://github.com/testkapua/testni-repo/actions/runs/123", + action_url="https://github.com/testkapua/testing-repo/actions/runs/123", ) message = notification.messages[0] - text = "\n\n".join(message.sections) embed = message.embeds[0] + text = message.content - 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 + assert "Failures" in embed.title + assert "### Built" in text + assert "`lab-1`" in text + assert "`lab-2`" in text + assert "### Failed" in text + assert "`project-base`" in text -def test_docker_notification_includes_error_section_for_failures(): +def test_docker_notification_with_error(): 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", - ] - ), + "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", @@ -141,12 +119,10 @@ def test_docker_notification_includes_error_section_for_failures(): author="robbykapua", author_icon="", branch="main", - action_url="https://github.com/testkapua/testni-repo/actions/runs/123", + action_url="https://github.com/testkapua/testing-repo/actions/runs/123", ) message = notification.messages[0] - text = "\n\n".join(message.sections) - - assert message.embeds[0].title == "Docker Update Needs Attention" - assert "## Error" in text - assert "ValueError: bad image" in text + assert "Error" in message.embeds[0].title + assert "### Errors" in message.content + assert "ValueError: bad image" in message.content diff --git a/tests/test_plain_text_chunking.py b/tests/test_plain_text_chunking.py deleted file mode 100644 index 955c24e..0000000 --- a/tests/test_plain_text_chunking.py +++ /dev/null @@ -1,114 +0,0 @@ -from notifications.resources import WebhookMessage -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( - [ - "## Status", - "**Action needed:** 2 items need review. 3 items were published.", - "> Updated content: Week 12 Overview, Lab 8 Instructions", - ] - ), - "\n".join( - [ - "## Needs Review", - "- Week 12 Overview: https://courses.example/week-12", - "- Lab 8 Instructions: https://courses.example/lab-8", - ] - ), - "\n".join( - [ - "## Published", - "- 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\n## Status") - assert "## Needs Review" in chunks[0] - assert chunks[1].startswith("Canvas details (continued)\n\n## Published") - 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] - - -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" From 03e34c9d3a10e896d3e6d851e8292184d342e458 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 14:27:07 -0600 Subject: [PATCH 21/43] Move Canvas/Docker notifications to rich embeds Replace plain-text content with rich Discord embeds matching the MDXCanvas deploy notification style: colored sidebar (green/yellow/red), author block with GitHub avatar, emoji resource-type badges, embed fields for deployed/review/failed items, and timestamps. Co-Authored-By: Claude Opus 4.6 --- .../send-course-notification/action.yml | 1 + notifications/formatting/canvas_format.py | 128 +++++++++++------- notifications/formatting/docker_format.py | 103 +++++++------- notifications/formatting/formatting_utils.py | 15 ++ notifications/formatting/plain_text_utils.py | 12 +- notifications/send_course.py | 6 +- tests/test_course_text_formatters.py | 76 +++++++---- 7 files changed, 211 insertions(+), 130 deletions(-) diff --git a/.github/actions/send-course-notification/action.yml b/.github/actions/send-course-notification/action.yml index 9f213ad..9652e16 100644 --- a/.github/actions/send-course-notification/action.yml +++ b/.github/actions/send-course-notification/action.yml @@ -58,6 +58,7 @@ runs: --course-name "${{ inputs.course-name }}" \ --course-url "${{ inputs.course-url }}" \ --author "${{ github.actor }}" \ + --author-icon "https://github.com/${{ github.actor }}.png" \ --branch "${{ github.ref_name }}" \ --cicd-id "${{ inputs.discord-role }}" \ --action-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index a97de49..0725e4b 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -1,9 +1,22 @@ -from notifications.formatting.formatting_utils import get_course_style, truncate_error +from datetime import datetime, timezone + +from notifications.formatting.formatting_utils import ( + emoji_for, + get_course_style, + truncate_error, +) from notifications.formatting.plain_text_utils import ( dedupe_remaining_content, status_color, ) -from notifications.resources import Notification, WebhookMessage, Embed, Footer +from notifications.resources import ( + Author, + Embed, + Field, + Footer, + Notification, + WebhookMessage, +) def has_content(data) -> bool: @@ -18,45 +31,12 @@ def requires_review(data) -> bool: return bool(data["content_to_review"] or data["error"]) -def _build_plain_text(data, course_name, course_url, author, branch, action_url): - sections = [] - - # --- Status --- - deployed_count = len(data["deployed_content"]) - review_count = len(data["content_to_review"]) - - if data["error"]: - sections.append(f"A publishing error was reported. {deployed_count} item(s) deployed.") - elif review_count: - sections.append(f"{review_count} item(s) need review. {deployed_count} item(s) deployed.") - else: - sections.append(f"{deployed_count} item(s) deployed successfully.") - - # --- Needs Review --- - if data["content_to_review"]: - lines = [f"- [{name}]({link})" for name, link in data["content_to_review"]] - sections.append("### Needs Review\n" + "\n".join(lines)) - - # --- Published (deduped) --- - remaining = dedupe_remaining_content(data["deployed_content"], data["content_to_review"]) - if remaining: - lines = [] - for label, url in remaining: - if url: - lines.append(f"- [{label}]({url})") - else: - lines.append(f"- {label}") - sections.append("### Published\n" + "\n".join(lines)) - - # --- Errors --- - if data["error"]: - truncated = truncate_error(data["error"]) - sections.append(f"### Errors\n{truncated}") - - # --- Metadata footer --- - sections.append(f"-# {author} | `{branch}` | [{course_name}]({course_url}) | [Action Log]({action_url})") - - return "\n\n".join(sections) +def _format_item(resource_type: str, name: str, link: str | None) -> str: + emoji = emoji_for(resource_type) + label = f"`{resource_type}`" + if link: + return f"{emoji} {label} [{name}]({link})" + return f"{emoji} {label} {name}" def format_notification( @@ -70,30 +50,76 @@ def format_notification( action_url, ) -> Notification: style = get_course_style("canvas") + timestamp = datetime.now(timezone.utc).isoformat() - title = "Canvas Deploy" + # ── Title ──────────────────────────────────────────────────────────── if data["error"]: - title = "Canvas Deploy -- Error" + title = f"{course_name} — Deploy failed" elif data["content_to_review"]: - title = "Canvas Deploy -- Review Needed" + title = f"{course_name} — Deploy complete — items need review" + else: + title = f"{course_name} — Deploy complete" + + # ── Color ──────────────────────────────────────────────────────────── + color = status_color( + has_error=bool(data["error"]), + needs_review=requires_review(data), + ) + + # ── Description ────────────────────────────────────────────────────── + description = f"**Branch:** `{branch}`" - color = status_color(has_error=bool(data["error"]), needs_review=requires_review(data)) - plain_text = _build_plain_text(data, course_name, course_url, author, branch, action_url) + if data["error"]: + truncated = truncate_error(data["error"]) + description += f"\n\n{truncated}" + + # ── Fields ─────────────────────────────────────────────────────────── + fields = [] + if data["content_to_review"]: + review_lines = "\n".join( + _format_item("Assignment", name, link) + for name, link in data["content_to_review"] + ) + fields.append(Field( + name=f"⚠️ Needs review ({len(data['content_to_review'])})", + value=review_lines, + inline=False, + )) + + remaining = dedupe_remaining_content( + data["deployed_content"], data["content_to_review"] + ) + if remaining: + deployed_lines = "\n".join( + _format_item(content_type, name, url) + for content_type, name, url in remaining + ) + fields.append(Field( + name=f"✅ Deployed ({len(remaining)})", + value=deployed_lines, + inline=False, + )) + + # ── Build notification ─────────────────────────────────────────────── return Notification( username=style["username"], avatar_url=style["avatar_url"], messages=[ WebhookMessage( - content=plain_text, + content=None, embeds=[ Embed( title=f"CS {course_id} | {title}", - description="", + description=description, color=color, - fields=[], - timestamp="", - footer=Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]), + fields=fields, + timestamp=timestamp, + author=Author(name=author, icon_url=author_icon), + footer=Footer( + text=style["footer_text"], + icon_url=style["footer_icon_url"], + ), ) ], ) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index 4063477..b21c978 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -1,6 +1,15 @@ +from datetime import datetime, timezone + from notifications.formatting.formatting_utils import get_course_style, truncate_error from notifications.formatting.plain_text_utils import status_color -from notifications.resources import Notification, WebhookMessage, Embed, Footer +from notifications.resources import ( + Author, + Embed, + Field, + Footer, + Notification, + WebhookMessage, +) def has_content(data) -> bool: @@ -15,41 +24,6 @@ def requires_review(data) -> bool: return bool(data["failed_images"] or data["error"]) -def _build_plain_text(data, course_name, course_url, author, branch, action_url): - sections = [] - - # --- Status --- - updated_count = len(data["updated_images"]) - failed_count = len(data["failed_images"]) - - if data["error"]: - sections.append(f"A build error was reported. {updated_count} image(s) built, {failed_count} failed.") - elif failed_count: - sections.append(f"{updated_count} image(s) built. {failed_count} image(s) failed.") - else: - sections.append(f"{updated_count} image(s) built successfully.") - - # --- Built --- - if data["updated_images"]: - lines = [f"- `{image}`" for image in data["updated_images"]] - sections.append("### Built\n" + "\n".join(lines)) - - # --- Failed --- - if data["failed_images"]: - lines = [f"- `{image}`" for image in data["failed_images"]] - sections.append("### Failed\n" + "\n".join(lines)) - - # --- Errors --- - if data["error"]: - truncated = truncate_error(data["error"]) - sections.append(f"### Errors\n{truncated}") - - # --- Metadata footer --- - sections.append(f"-# {author} | `{branch}` | [{course_name}]({course_url}) | [Action Log]({action_url})") - - return "\n\n".join(sections) - - def format_notification( data, course_id, @@ -61,30 +35,67 @@ def format_notification( action_url, ) -> Notification: style = get_course_style("docker") + timestamp = datetime.now(timezone.utc).isoformat() - title = "Docker Build" + # ── Title ──────────────────────────────────────────────────────────── if data["error"]: - title = "Docker Build -- Error" + title = f"{course_name} — Build failed" elif data["failed_images"]: - title = "Docker Build -- Failures" + title = f"{course_name} — Build complete — failures" + else: + title = f"{course_name} — Build complete" - color = status_color(has_error=bool(data["error"]), needs_review=requires_review(data)) - plain_text = _build_plain_text(data, course_name, course_url, author, branch, action_url) + # ── Color ──────────────────────────────────────────────────────────── + color = status_color( + has_error=bool(data["error"]), + needs_review=requires_review(data), + ) + + # ── Description ────────────────────────────────────────────────────── + description = f"**Branch:** `{branch}`" + if data["error"]: + truncated = truncate_error(data["error"]) + description += f"\n\n{truncated}" + + # ── Fields ─────────────────────────────────────────────────────────── + fields = [] + + if data["failed_images"]: + failed_lines = "\n".join(f"❌ `{image}`" for image in data["failed_images"]) + fields.append(Field( + name=f"❌ Failed ({len(data['failed_images'])})", + value=failed_lines, + inline=False, + )) + + if data["updated_images"]: + built_lines = "\n".join(f"📦 `{image}`" for image in data["updated_images"]) + fields.append(Field( + name=f"✅ Built ({len(data['updated_images'])})", + value=built_lines, + inline=False, + )) + + # ── Build notification ─────────────────────────────────────────────── return Notification( username=style["username"], avatar_url=style["avatar_url"], messages=[ WebhookMessage( - content=plain_text, + content=None, embeds=[ Embed( title=f"CS {course_id} | {title}", - description="", + description=description, color=color, - fields=[], - timestamp="", - footer=Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]), + fields=fields, + timestamp=timestamp, + author=Author(name=author, icon_url=author_icon), + footer=Footer( + text=style["footer_text"], + icon_url=style["footer_icon_url"], + ), ) ], ) diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index b0c3a83..1327b58 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -1,6 +1,21 @@ from markdowndata import load from pathlib import Path +# ── Resource type badge emoji mapping ──────────────────────────────────────── +RESOURCE_EMOJI: dict[str, str] = { + "Page": "📄", + "Quiz": "📝", + "Assignment": "📎", + "Module": "📦", + "Discussion": "💬", + "File": "📁", + "ExternalUrl": "🔗", +} + + +def emoji_for(resource_type: str) -> str: + return RESOURCE_EMOJI.get(resource_type, "📌") + STYLE_PATH = Path(__file__).parent / "style.md" diff --git a/notifications/formatting/plain_text_utils.py b/notifications/formatting/plain_text_utils.py index 6b5a567..b42ea4a 100644 --- a/notifications/formatting/plain_text_utils.py +++ b/notifications/formatting/plain_text_utils.py @@ -1,8 +1,8 @@ from __future__ import annotations -SUCCESS_COLOR = 0x2E8B57 -REVIEW_COLOR = 0xD97706 -ERROR_COLOR = 0xDC2626 +SUCCESS_COLOR = 0x57F287 # Green +REVIEW_COLOR = 0xFEE75C # Yellow +ERROR_COLOR = 0xED4245 # Red def status_color(*, has_error: bool, needs_review: bool) -> int: @@ -16,7 +16,7 @@ def status_color(*, has_error: bool, needs_review: bool) -> int: def dedupe_remaining_content( deployed_content: list[tuple[str, str, str | None]], review_items: list[tuple[str, str]], -) -> list[tuple[str, str | None]]: +) -> list[tuple[str, str, str | None]]: review_urls = {url for _, url in review_items if url} review_labels = {label for label, _ in review_items} @@ -24,7 +24,7 @@ def dedupe_remaining_content( seen_labels: set[str] = set() remaining = [] - for _, label, url in deployed_content: + for content_type, label, url in deployed_content: if url and url in review_urls: continue if label in review_labels: @@ -37,6 +37,6 @@ def dedupe_remaining_content( if url: seen_urls.add(url) seen_labels.add(label) - remaining.append((label, url)) + remaining.append((content_type, label, url)) return remaining diff --git a/notifications/send_course.py b/notifications/send_course.py index 11a15d0..b405cad 100644 --- a/notifications/send_course.py +++ b/notifications/send_course.py @@ -12,7 +12,7 @@ } -def main(ntype, payload, course_id, course_name, course_url, author, branch_name, action_url, cicd_role_id): +def main(ntype, payload, course_id, course_name, course_url, 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.") @@ -35,7 +35,7 @@ def main(ntype, payload, course_id, course_name, course_url, author, branch_name course_name=course_name, course_url=course_url, author=author, - author_icon="", + author_icon=author_icon or "", branch=branch_name, action_url=action_url, ) @@ -56,6 +56,7 @@ def main(ntype, payload, course_id, course_name, course_url, author, branch_name parser.add_argument("--author", required=True, help="Name of the author") parser.add_argument("--branch", required=True, help="Branch name") parser.add_argument("--action-url", required=True, help="URL to the GHA") + parser.add_argument("--author-icon", nargs='?', const=None, default=None, help="Author avatar URL") parser.add_argument("--cicd-id", nargs='?', const=None, default=None, help="CI/CD Role ID") args = parser.parse_args() @@ -67,6 +68,7 @@ def main(ntype, payload, course_id, course_name, course_url, author, branch_name args.course_name, args.course_url, args.author, + args.author_icon, args.branch, args.action_url, args.cicd_id, diff --git a/tests/test_course_text_formatters.py b/tests/test_course_text_formatters.py index 96a2139..40e6e5a 100644 --- a/tests/test_course_text_formatters.py +++ b/tests/test_course_text_formatters.py @@ -21,27 +21,42 @@ def test_canvas_notification_with_review_items(): course_name="CS 235 Spring 2026", course_url="https://courses.example/cs235", author="robbykapua", - author_icon="", + author_icon="https://github.com/robbykapua.png", branch="main", action_url="https://github.com/testkapua/testing-repo/actions/runs/123", ) message = notification.messages[0] embed = message.embeds[0] - text = message.content - # Embed is the header - assert "Review Needed" in embed.title + # Content is None (reserved for role mentions only) + assert message.content is None + + # Embed metadata + assert "Review" in embed.title or "review" in embed.title + assert embed.author is not None + assert embed.author.name == "robbykapua" assert embed.footer is not None + assert embed.timestamp + + # Description has branch info + assert "`main`" in embed.description - # Content is plain 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 - # Review items deduped from published - assert text.count("https://courses.example/review-me") == 1 - assert "Action Log" in text + # Fields: review items + deployed items + assert len(embed.fields) == 2 + + review_field = embed.fields[0] + assert "Needs review" in review_field.name + assert "(2)" in review_field.name + assert "Needs Review" in review_field.value + assert "Professor Approval" in review_field.value + + deployed_field = embed.fields[1] + assert "Deployed" in deployed_field.name + # Review items deduped from deployed + assert "https://courses.example/review-me" not in deployed_field.value + assert "Week 12 Overview" in deployed_field.value + assert "Lab 8 Instructions" in deployed_field.value def test_canvas_notification_with_error(): @@ -67,9 +82,11 @@ def test_canvas_notification_with_error(): ) message = notification.messages[0] - assert "Error" in message.embeds[0].title - assert "### Errors" in message.content - assert "RuntimeError: boom" in message.content + embed = message.embeds[0] + + assert "failed" in embed.title + assert "RuntimeError: boom" in embed.description + assert message.content is None def test_docker_notification_with_failures(): @@ -90,14 +107,21 @@ def test_docker_notification_with_failures(): message = notification.messages[0] embed = message.embeds[0] - text = message.content - assert "Failures" in embed.title - assert "### Built" in text - assert "`lab-1`" in text - assert "`lab-2`" in text - assert "### Failed" in text - assert "`project-base`" in text + assert message.content is None + assert "failures" in embed.title.lower() + + # Failed field first, then built + assert len(embed.fields) == 2 + + failed_field = embed.fields[0] + assert "Failed" in failed_field.name + assert "`project-base`" in failed_field.value + + built_field = embed.fields[1] + assert "Built" in built_field.name + assert "`lab-1`" in built_field.value + assert "`lab-2`" in built_field.value def test_docker_notification_with_error(): @@ -123,6 +147,8 @@ def test_docker_notification_with_error(): ) message = notification.messages[0] - assert "Error" in message.embeds[0].title - assert "### Errors" in message.content - assert "ValueError: bad image" in message.content + embed = message.embeds[0] + + assert "failed" in embed.title.lower() + assert "ValueError: bad image" in embed.description + assert message.content is None From 280e13ce01efd61bcbf9a884a6ca4724fdb91f69 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 14:30:21 -0600 Subject: [PATCH 22/43] Add whitespace and block quotes to embed fields Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/canvas_format.py | 8 ++++---- notifications/formatting/docker_format.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index 0725e4b..280848b 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -78,11 +78,11 @@ def format_notification( if data["content_to_review"]: review_lines = "\n".join( - _format_item("Assignment", name, link) + f"> {_format_item('Assignment', name, link)}" for name, link in data["content_to_review"] ) fields.append(Field( - name=f"⚠️ Needs review ({len(data['content_to_review'])})", + name=f"\n⚠️ Needs review ({len(data['content_to_review'])})", value=review_lines, inline=False, )) @@ -92,11 +92,11 @@ def format_notification( ) if remaining: deployed_lines = "\n".join( - _format_item(content_type, name, url) + f"> {_format_item(content_type, name, url)}" for content_type, name, url in remaining ) fields.append(Field( - name=f"✅ Deployed ({len(remaining)})", + name=f"\n✅ Deployed ({len(remaining)})", value=deployed_lines, inline=False, )) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index b21c978..c817092 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -62,17 +62,17 @@ def format_notification( fields = [] if data["failed_images"]: - failed_lines = "\n".join(f"❌ `{image}`" for image in data["failed_images"]) + failed_lines = "\n".join(f"> ❌ `{image}`" for image in data["failed_images"]) fields.append(Field( - name=f"❌ Failed ({len(data['failed_images'])})", + name=f"\n❌ Failed ({len(data['failed_images'])})", value=failed_lines, inline=False, )) if data["updated_images"]: - built_lines = "\n".join(f"📦 `{image}`" for image in data["updated_images"]) + built_lines = "\n".join(f"> 📦 `{image}`" for image in data["updated_images"]) fields.append(Field( - name=f"✅ Built ({len(data['updated_images'])})", + name=f"\n✅ Built ({len(data['updated_images'])})", value=built_lines, inline=False, )) From db7275f22639c5b02c2aac48ecd6a5c1d0786ce8 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 14:47:30 -0600 Subject: [PATCH 23/43] Add spacer fields between major embed sections Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/canvas_format.py | 9 +++++++-- notifications/formatting/docker_format.py | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index 280848b..3c829df 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -74,6 +74,7 @@ def format_notification( description += f"\n\n{truncated}" # ── Fields ─────────────────────────────────────────────────────────── + SPACER = Field(name="\u200b", value="\u200b", inline=False) fields = [] if data["content_to_review"]: @@ -81,8 +82,10 @@ def format_notification( f"> {_format_item('Assignment', name, link)}" for name, link in data["content_to_review"] ) + if fields: + fields.append(SPACER) fields.append(Field( - name=f"\n⚠️ Needs review ({len(data['content_to_review'])})", + name=f"⚠️ Needs review ({len(data['content_to_review'])})", value=review_lines, inline=False, )) @@ -95,8 +98,10 @@ def format_notification( f"> {_format_item(content_type, name, url)}" for content_type, name, url in remaining ) + if fields: + fields.append(SPACER) fields.append(Field( - name=f"\n✅ Deployed ({len(remaining)})", + name=f"✅ Deployed ({len(remaining)})", value=deployed_lines, inline=False, )) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index c817092..1b9977a 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -59,20 +59,25 @@ def format_notification( description += f"\n\n{truncated}" # ── Fields ─────────────────────────────────────────────────────────── + SPACER = Field(name="\u200b", value="\u200b", inline=False) fields = [] if data["failed_images"]: failed_lines = "\n".join(f"> ❌ `{image}`" for image in data["failed_images"]) + if fields: + fields.append(SPACER) fields.append(Field( - name=f"\n❌ Failed ({len(data['failed_images'])})", + name=f"❌ Failed ({len(data['failed_images'])})", value=failed_lines, inline=False, )) if data["updated_images"]: built_lines = "\n".join(f"> 📦 `{image}`" for image in data["updated_images"]) + if fields: + fields.append(SPACER) fields.append(Field( - name=f"\n✅ Built ({len(data['updated_images'])})", + name=f"✅ Built ({len(data['updated_images'])})", value=built_lines, inline=False, )) From e79b891fbca3099ba44adee72ca950a8f6dbe8dc Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 14:57:43 -0600 Subject: [PATCH 24/43] Split long item lists across multiple fields for Discord limits Discord field values are capped at 1024 chars. With 170+ deployed items, a single field overflows and Discord returns 400. Added chunk_field_lines() to split lines into field-sized chunks, and continuation fields use zero-width space headers. Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/canvas_format.py | 35 ++++++++++++-------- notifications/formatting/docker_format.py | 32 ++++++++++-------- notifications/formatting/formatting_utils.py | 33 ++++++++++++++++++ 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index 3c829df..e6971c7 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone from notifications.formatting.formatting_utils import ( + chunk_field_lines, emoji_for, get_course_style, truncate_error, @@ -78,33 +79,39 @@ def format_notification( fields = [] if data["content_to_review"]: - review_lines = "\n".join( + lines = [ f"> {_format_item('Assignment', name, link)}" for name, link in data["content_to_review"] - ) + ] + chunks = chunk_field_lines(lines) + header = f"⚠️ Needs review ({len(data['content_to_review'])})" if fields: fields.append(SPACER) - fields.append(Field( - name=f"⚠️ Needs review ({len(data['content_to_review'])})", - value=review_lines, - inline=False, - )) + for i, chunk in enumerate(chunks): + fields.append(Field( + name=header if i == 0 else "\u200b", + value=chunk, + inline=False, + )) remaining = dedupe_remaining_content( data["deployed_content"], data["content_to_review"] ) if remaining: - deployed_lines = "\n".join( + lines = [ f"> {_format_item(content_type, name, url)}" for content_type, name, url in remaining - ) + ] + chunks = chunk_field_lines(lines) + header = f"✅ Deployed ({len(remaining)})" if fields: fields.append(SPACER) - fields.append(Field( - name=f"✅ Deployed ({len(remaining)})", - value=deployed_lines, - inline=False, - )) + for i, chunk in enumerate(chunks): + fields.append(Field( + name=header if i == 0 else "\u200b", + value=chunk, + inline=False, + )) # ── Build notification ─────────────────────────────────────────────── return Notification( diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index 1b9977a..e38dbb0 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from notifications.formatting.formatting_utils import get_course_style, truncate_error +from notifications.formatting.formatting_utils import chunk_field_lines, get_course_style, truncate_error from notifications.formatting.plain_text_utils import status_color from notifications.resources import ( Author, @@ -63,24 +63,30 @@ def format_notification( fields = [] if data["failed_images"]: - failed_lines = "\n".join(f"> ❌ `{image}`" for image in data["failed_images"]) + lines = [f"> ❌ `{image}`" for image in data["failed_images"]] + chunks = chunk_field_lines(lines) + header = f"❌ Failed ({len(data['failed_images'])})" if fields: fields.append(SPACER) - fields.append(Field( - name=f"❌ Failed ({len(data['failed_images'])})", - value=failed_lines, - inline=False, - )) + for i, chunk in enumerate(chunks): + fields.append(Field( + name=header if i == 0 else "\u200b", + value=chunk, + inline=False, + )) if data["updated_images"]: - built_lines = "\n".join(f"> 📦 `{image}`" for image in data["updated_images"]) + lines = [f"> 📦 `{image}`" for image in data["updated_images"]] + chunks = chunk_field_lines(lines) + header = f"✅ Built ({len(data['updated_images'])})" if fields: fields.append(SPACER) - fields.append(Field( - name=f"✅ Built ({len(data['updated_images'])})", - value=built_lines, - inline=False, - )) + for i, chunk in enumerate(chunks): + fields.append(Field( + name=header if i == 0 else "\u200b", + value=chunk, + inline=False, + )) # ── Build notification ─────────────────────────────────────────────── return Notification( diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index 1327b58..9d49002 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -16,6 +16,39 @@ def emoji_for(resource_type: str) -> str: return RESOURCE_EMOJI.get(resource_type, "📌") + +# ── Field chunking (Discord field value limit: 1024 chars) ────────────────── +MAX_FIELD_CHARS = 1024 + + +def chunk_field_lines( + lines: list[str], + max_chars: int = MAX_FIELD_CHARS, +) -> list[str]: + """Split a list of formatted lines into chunks that fit Discord's field value limit. + + Returns a list of joined strings, each under *max_chars*. + """ + chunks: list[str] = [] + current_lines: list[str] = [] + current_len = 0 + + for line in lines: + needed = len(line) + (1 if current_lines else 0) # +1 for newline + if current_lines and current_len + needed > max_chars: + chunks.append("\n".join(current_lines)) + current_lines = [line] + current_len = len(line) + else: + current_lines.append(line) + current_len += needed + + if current_lines: + chunks.append("\n".join(current_lines)) + + return chunks + + STYLE_PATH = Path(__file__).parent / "style.md" From 9d5465e9da1ab065545c302cebbb50128479c80b Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 15:01:02 -0600 Subject: [PATCH 25/43] Use visible separator line for spacers between embed sections Zero-width space spacers were invisible. Replace with a thin horizontal dash line for clear visual separation. Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/canvas_format.py | 2 +- notifications/formatting/docker_format.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index e6971c7..dfa9e5a 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -75,7 +75,7 @@ def format_notification( description += f"\n\n{truncated}" # ── Fields ─────────────────────────────────────────────────────────── - SPACER = Field(name="\u200b", value="\u200b", inline=False) + SPACER = Field(name="\u200b", value="─────────────────────────", inline=False) fields = [] if data["content_to_review"]: diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index e38dbb0..32a9723 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -59,7 +59,7 @@ def format_notification( description += f"\n\n{truncated}" # ── Fields ─────────────────────────────────────────────────────────── - SPACER = Field(name="\u200b", value="\u200b", inline=False) + SPACER = Field(name="\u200b", value="─────────────────────────", inline=False) fields = [] if data["failed_images"]: From 5309ca182f44ca8a6449df3952ca7c4214926b6c Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 15:03:22 -0600 Subject: [PATCH 26/43] Stack two invisible spacer fields for visible gap between sections Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/canvas_format.py | 9 ++++++--- notifications/formatting/docker_format.py | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index dfa9e5a..bea4ec1 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -75,7 +75,10 @@ def format_notification( description += f"\n\n{truncated}" # ── Fields ─────────────────────────────────────────────────────────── - SPACER = Field(name="\u200b", value="─────────────────────────", inline=False) + SPACER = [ + Field(name="\u200b", value="\u200b", inline=False), + Field(name="\u200b", value="\u200b", inline=False), + ] fields = [] if data["content_to_review"]: @@ -86,7 +89,7 @@ def format_notification( chunks = chunk_field_lines(lines) header = f"⚠️ Needs review ({len(data['content_to_review'])})" if fields: - fields.append(SPACER) + fields.extend(SPACER) for i, chunk in enumerate(chunks): fields.append(Field( name=header if i == 0 else "\u200b", @@ -105,7 +108,7 @@ def format_notification( chunks = chunk_field_lines(lines) header = f"✅ Deployed ({len(remaining)})" if fields: - fields.append(SPACER) + fields.extend(SPACER) for i, chunk in enumerate(chunks): fields.append(Field( name=header if i == 0 else "\u200b", diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index 32a9723..757bcf8 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -59,7 +59,10 @@ def format_notification( description += f"\n\n{truncated}" # ── Fields ─────────────────────────────────────────────────────────── - SPACER = Field(name="\u200b", value="─────────────────────────", inline=False) + SPACER = [ + Field(name="\u200b", value="\u200b", inline=False), + Field(name="\u200b", value="\u200b", inline=False), + ] fields = [] if data["failed_images"]: @@ -67,7 +70,7 @@ def format_notification( chunks = chunk_field_lines(lines) header = f"❌ Failed ({len(data['failed_images'])})" if fields: - fields.append(SPACER) + fields.extend(SPACER) for i, chunk in enumerate(chunks): fields.append(Field( name=header if i == 0 else "\u200b", @@ -80,7 +83,7 @@ def format_notification( chunks = chunk_field_lines(lines) header = f"✅ Built ({len(data['updated_images'])})" if fields: - fields.append(SPACER) + fields.extend(SPACER) for i, chunk in enumerate(chunks): fields.append(Field( name=header if i == 0 else "\u200b", From 2b74728ec550767ac985e66bdb39c831e22481b2 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 15:08:40 -0600 Subject: [PATCH 27/43] Fix emoji mapping: case-insensitive lookup + add missing types MDXCanvas outputs lowercase types (page, quiz, module_item) but the emoji map had PascalCase keys. Normalized to lowercase lookup and added module_item, announcement, syllabus types. Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/formatting_utils.py | 21 ++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index 9d49002..32ba2ed 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -2,19 +2,24 @@ from pathlib import Path # ── Resource type badge emoji mapping ──────────────────────────────────────── +# Keys are lowercase for case-insensitive matching. RESOURCE_EMOJI: dict[str, str] = { - "Page": "📄", - "Quiz": "📝", - "Assignment": "📎", - "Module": "📦", - "Discussion": "💬", - "File": "📁", - "ExternalUrl": "🔗", + "page": "📄", + "quiz": "📝", + "assignment": "📎", + "module": "📦", + "module_item": "📂", + "discussion": "💬", + "file": "📁", + "externalurl": "🔗", + "external_url": "🔗", + "announcement": "📢", + "syllabus": "📋", } def emoji_for(resource_type: str) -> str: - return RESOURCE_EMOJI.get(resource_type, "📌") + return RESOURCE_EMOJI.get(resource_type.lower(), "📌") # ── Field chunking (Discord field value limit: 1024 chars) ────────────────── From ad80857d53e4940c632554749a9ebc899aee48ec Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 15:10:02 -0600 Subject: [PATCH 28/43] Remove type label from item lines, keep emoji only Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/canvas_format.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index bea4ec1..6a49e4a 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -34,10 +34,9 @@ def requires_review(data) -> bool: def _format_item(resource_type: str, name: str, link: str | None) -> str: emoji = emoji_for(resource_type) - label = f"`{resource_type}`" if link: - return f"{emoji} {label} [{name}]({link})" - return f"{emoji} {label} {name}" + return f"{emoji} [{name}]({link})" + return f"{emoji} {name}" def format_notification( From 159e4ef105661005ca4e376efdc99f0bfaa23485 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 1 Apr 2026 15:15:11 -0600 Subject: [PATCH 29/43] Use thumbtack emoji for all items with type labels Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/canvas_format.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index 6a49e4a..32fb006 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -33,10 +33,10 @@ def requires_review(data) -> bool: def _format_item(resource_type: str, name: str, link: str | None) -> str: - emoji = emoji_for(resource_type) + label = f"`{resource_type}`" if link: - return f"{emoji} [{name}]({link})" - return f"{emoji} {name}" + return f"📌 {label} [{name}]({link})" + return f"📌 {label} {name}" def format_notification( From 5b8c77a75396d58c3ca32f0f9df163d3a190a768 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 8 Apr 2026 12:38:25 -0600 Subject: [PATCH 30/43] Add discord_limits module with constants, calc_embed_size, split_content Co-Authored-By: Claude Sonnet 4.6 --- notifications/discord_limits.py | 54 ++ tests/failed-mdxcanvas-notification.png | Bin 0 -> 38140 bytes tests/success-mdxcanvas-notification.png | Bin 0 -> 45695 bytes tests/test-mdxcanvas-payload.json | 826 +++++++++++++++++++++++ tests/test_discord_limits.py | 80 +++ tests/urgent-mdxcanvas-notification.png | Bin 0 -> 61376 bytes 6 files changed, 960 insertions(+) create mode 100644 notifications/discord_limits.py create mode 100644 tests/failed-mdxcanvas-notification.png create mode 100644 tests/success-mdxcanvas-notification.png create mode 100644 tests/test-mdxcanvas-payload.json create mode 100644 tests/test_discord_limits.py create mode 100644 tests/urgent-mdxcanvas-notification.png diff --git a/notifications/discord_limits.py b/notifications/discord_limits.py new file mode 100644 index 0000000..847ef46 --- /dev/null +++ b/notifications/discord_limits.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from .resources import Embed + +# -- Discord API limits ------------------------------------------------------- +TITLE_LIMIT = 256 +DESCRIPTION_LIMIT = 4096 +FIELD_NAME_LIMIT = 256 +FIELD_VALUE_LIMIT = 1024 +FIELDS_PER_EMBED = 25 +FOOTER_LIMIT = 2048 +AUTHOR_NAME_LIMIT = 256 +EMBED_CHAR_LIMIT = 6000 +MAX_EMBEDS_PER_MESSAGE = 10 +CONTENT_LIMIT = 2000 + + +def calc_embed_size(embed: Embed) -> int: + 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 split_content(content: str, max_chars: int = CONTENT_LIMIT) -> list[str]: + if len(content) <= max_chars: + return [content] + + paragraphs = content.split("\n\n") + chunks: list[str] = [] + 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 diff --git a/tests/failed-mdxcanvas-notification.png b/tests/failed-mdxcanvas-notification.png new file mode 100644 index 0000000000000000000000000000000000000000..8e19f3bbf3c70fa21f2357a21138ac0ba6ac21b6 GIT binary patch literal 38140 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
FarjJW{Q-PRL8>|%qJJ$mVk0;I65`L= z%z%RaAj!ZoY1f29BU20Bc=^8_DwFc$x=bIeqHlUiRG5-j@bT*@wxe~YuZKEaEr%Cv zWaU=k)e(Uau;WDvJxew2CObd=h|4h8Nt*Sa$6o`!vg10OH+NQXTn!h%^7jqe$d(8$ zF#qjU`r`ogAEu10z$~q!J_|AzEmqMc3g>iRpWF_ru_iY#bx8L7uLl#N@_gZhKESb8 zvxJ9D<2_4Pdn#gg9@CqyH=+AiZ+qWAKERSdSE+%WDS%of`8cGYw{fKk7V-GZx}6*E zWs^w%?R9m8KESqj1&(M9)^uq4Mk8hv(&<>F<1Ba{Tk@jlESEw+^jU z!-;0=o#W?!I{G;JK9&i}?1Y9?B? zTbw>=^_vF=(vX|35a`jm4=$juwYs%^-U>L^pnFwn-jmt9FKn<|NuRP zD7PVEyg;22@VWtCQa=}ef%3)lXx$);bzl8JmS=LOK_Po-v9lg0ZX)w|dHI1pHZS4b z^X=N_cX$J46+WFYqWhD*vTyP53UQN8O@0}Qmzc-p9zxy5#bKEaO^w=(t+qW}GO3on z-x*8M--!teD@4I(p6U(>1AK|OEY{59({1+t4nVeR1&mFXYZV}8@cC%He$p(5a{ubt z*6i{dQ!~sM{O~@s!#nf#W~G zww@z)45he?C3BAK?d_FiUU*;av(^obj3i|Wa=y}XP8m(roai-UEwpUsW*hkx6%p{N zAp#R%C#lxgcQ(&-+nWl8cl#=^wv~4o9XIxJiPB1n1IF3SFzu2(*TxGYuR1n_*WggL z^XSzpsUN9sR=w<$m$+=E*~#BgQVzsrb>8o$lg>D)NXC$reXj9aliy#wKDt_Q6WW(2 z<_1mVft1JaqQb%q+8w5wRb>xX+faNiPcWWP$52X_xoosb5W=@SqF6rFBL6zy>X8l$ zj8kijY2fw&|L$Kj&833XX{gX07#riB5Cq~o?UaJdd&5;NvkLBG7v|M^G(jZy|ir%xF6568S|JPvFd8~ZH${9cDsE%7j8GH`){Kz&1x|bZAd4HMjDoIue z+}2)BS=L#(%1W6|Hb?_N&-eG!bi?V`z5Il){z`mxfH_57e1fHqp4X90{_X4IdG~Fx z+|ewD^&R5%uwe~?2HixPv2OJ*Crl_as!XNf8u34OGA#+I4IOY98m`|1&=Fv|F5bD!8h!Ix^&QD-nG`8tx9?kT=WYVu75qjl}gg#v>0_y28NG+Rh^^IIT0{neamt?@F%EukusxMH7kJ5ll zBq5nBf#{%a{xhHAdf6lf>6ud1f_fY6snm(JsYpU0y0a+38eFw=`%7-V_RrjQ%el`8 z=>DMi1N=tf(*>;A~=k|k1FzvG*FbMKngK!rDv;x}jN7A@*7KBE!p6t6 zrY+db$FreeDL~E#mCX-nT!r!8<#^ZLQF#Uw(X> zPB|(K+D_S#tIAKv(aFguj%{FI%6V^8_d0^R#E4n1S5+EwzP1}9$wfhn|$a-7%W-W0|{76GNO;QUvhcL-^-HBmbwgyK{GXZV`iqgM?^*%pKWST zIkw+Dv45itaPd51Y_nHHdJ1k`t2P7oy4sX{Ce@vv>+es$&lmEBVU|{~uL3(hJU-G_ z7OF8PlWLeFAcWY@4AF> zGLbp@nPHQ1bJCd_@p~aPFinZdJc3g)oAT*$=RAkQWRQbuU|hXOtIm?-@PjaoMs~&q z%h*6=yW(m}HFKik5~r1l^2iTXBQ=>3x!Sdu-p$|2!#$rsrDESl-e-fRmzz+`*E@Wt z^Bs(|8U!sW@kIWZXROLHB1&HrvIn<%4OY-e^;G%2TQA=mhw95RWf{;~y*!C~R&I`O zytA0-YAS_AK=2yO%6d$8xKm(99E@1LwHcf~P|5S;tTwBB|5M}FW?;IM=WuWDces1u zQ#-ZvRfw9Mhgpx{wHJ=dTqu%)=;2jk{^|GU48iWz*=LmoTT&4KH)A571z6?9dUzY~ zM)WRCP^tInyqh3z)82Mp-k5eZlC8(?Eu&JP;M2Om%dhn^AP8~jwZ^v1lBODUhwqPV zOy7X7TB;Ivd}7@cvr09CfttDWJAr!K=Z4G|0BG-Lg}FlrsI+4xVmViyxw&CT#-fzC z;|diuMZsg3jc{>KT59l%0}2S-NovF1rZoR3jv?%NRWACZq}Sb`dDXqOT<10Rb|~15 zSST^rn-z6L#@N@l{f{}LD#Y;Pc2u{;Vzm|S!5cBeZ*tE_@K)z-Z-P&7-*{b%9l^ie&K)d!Rc{D(o@y_eMXCyxM%w?ekZ?zkfANYr-Mhcg8g zPwzUC{Cd_A=5s_t=DUTIGtOd!qVo_i$VvfHiw5VZ4K_=`%@>?f;Yi?3t+fq+<-VT< z?fST_cO~gI;9XiLf7B@yz;{|zHaCB?M!oV>rOpxe@ZJI7v&vgzHu*cDC^3X6ER-yg zijqkom9YnsLiqaQQ)(rWKGl{Tu0R#nQXSdT8^T- z>kB+DG*IZAro*wojL7SYA}V3dsLpCbOM{(Os;i<+WAyx@lnD8?wuGZ!CmOh>5}Bm9ej|@$m;&Q z@U_FE*N7JGg9PF{HoyCR)gZiGnknXIoF@5P{E#IIGV+QnphxbBnymiKD#U5sPdl;< zzo_`tgG|sd)pX<`3?X@W+aH>_Owe>7i(j3|a-x+miT)P4T&F3V%WgrYz++rW8GWhC z))CNEz0Bo3%2B4*_5mgQ$g+mRi;`j)(ndsRETd9Pa*qj6>a*$r9_K%E-aNlY!_Dmf zLZZz+g(%?jkch{q_4AID*D+GGtGP1h&>9eo zvu_UN9KPB!dBv=wp_|2-joPlY=Pic#w78wA9x28temLURk5Sq@NihLUZvURT(<2)) zwBr#j{$6vj)u#|A5_Hh8=3DgoY}bT67JT3A{1OYoIV|bq6MDX4zm0M#!0HY;5VC62 zgTM{mGq+dBHjT1B`NfQl(qvt6F-U;WelB<|>ro zP9M$OebPCtx^hp2t2gD!-ekEUiavoXW}97PRafnnF_hWeJ0`N!Yqm!(?=BLyf9oAT z-GfVxF%RziRe}&M%x9}ro$5TFAJ7HM$P*Rtr;d+WEgKHa_}eBKIg z7ZBe8)fv*lk?M=y6WvlaV7sWKmDeD6w2lXcw+bfbW(nr68#N2F!Y%pc3^q3S0^zK` z_wCFTwMKao&CnBzkhkDSH6WnVgg*L*M34&jrqzM;#^D3J5>)5a&CaAiFEFZ_Sw4KB zB*&!1l4Glfb2rrm5%VYx(kCtU7r8IN@t}`Tc=pZdB9=sPDUVvh#}HXnuv;bOK|z#z zpR_degf?;hhLIt>*3tWN9<Qm#HEsgh!KR5NPne zVQ3sPQ#=(rnAxrS0fN1;_`L}SB&XUi@(dQx#dK)Q!H>DsB1leIeqh97*>^_^FoV)V za3?3`w3cH2Wo7{vL8bwNw@EWE`W2H-V|Uc#1-^ofxYfWOi9!E~R@G)MqXp`iAyn01 zoBYN+Ee!!>?}*zrjhbe$F9xw23hNwI?mIBfa<26A>&bFCSjYpM_;7VlwioGCqFPgU zSkz?LD?`t_p`Rac5}rgq^Q-WA#<8aO8v?@a@nNnY4C0Ux2Z!D+YU{f6@nRi2 zh6qoYNdG{J&cf~>>!+jf4cwX0vw0_vqg}>_TaYd=t*I8wP_Fri;j$;pUDde^rBBST z(UbQhy=H)&)BX2&yX{l=aZlJ*DZ@5hkI&7YIQfl*4=b~7yU0|LTrS3{pZXc&A-au%y# zH&p7N76k+1C)!VAKiJ$ z1YJ)V{BDJF`hHOYlYAI%M{4CNv?O`U3Dw)9PG_q=gW7Y2HuQKyvPjD!3rnaui1N%8 zW;-%T2i|OX*6+x4a)?1UdrmQGas#?Ms*acCpL6EWu_a0tdA%mD>)JH41#+vjX@1w4 zn+)of!w9sss_!(^Ipv2u`V~leB^TR3qBOysdQB}y{gVk1EzTaxj>@aSD=vCGNtAXX zMVW?)tos)WCYA%c!4g$R`;R z+h_{mbMgqmeyqo`&Ha03meM=ySCbzZ_^OMB6nb)MT1+uJ3*r597U=L)8^gNSqu8#X z%{}6^yQZK-iOCZI4pKN249j9#_0|=zIy-|&D*B+;=QHjk{0%<2FQtLTt54p~)2p&6 zkz+>lRbvb9-u^b|XCd1jmw$fqb$sF&$!TmTFMyFXu#&HR)gSph1~xFmAk7W?h@!Jo z!W$AEPtxdZA7Y>;Cs;hcBEqzX1W@0*hoK1oNcmc2|PYI5@Lv`o5^`lf|TX}14352M>K+KKZqdO_)RTaYzYnar5 zHk|tbp|+@)#NxwG%pirr$Wp)COdnILE;H0Qgq%AMp)sz(04BJ!uie*dPYfjdt~yB4 zPeK-JG>e@cKHi8v252^O6>U##D&Z}-Z;j*+Rj)nzuIMoBnL$tShb{G6YZ<{I?^}HD z&apDV#mx`H+q()tv2{po+BR$Q4;qmZ&eAyr=nCWzWr~-hxiU!`4H6OO%~y!JI3hiZ zh3hTJyc$c#m*@w(aM+&m!`nG4s;e?)7GQVQ=cBfTeJc7Z&cO2`_*1lpv(SgOBkzwM zmY}@o8W-m+fQGB_+4q=SQRpj^-{F`8qp;Yg(Q4)eWf6?M;1o--{L_dTWN|H(KpYG$ zd!w`TIy9eh=%y{+pOA>~Aque`Cc#6B@VL3pOsQJMPe78({U9#x;zwFaPM^AU%`Wp# zPn1+zrH9f(+D@l$G5j5$?k6_p4l8p?DQ@KrnrLcflOY@q4$GnIHZZmdg` z1t}iQRhHcjejKN0&0P>ALo%bCw})x**eWC<{|2OzNmk*;%Re)WNzj*GNojEPmKiz% z>Y@yX2xL?tTx1-pS7?sHdDMol zT(KR{O9K;3#Qh@a6L;IVM)UynJzU!-_%6v6*hK)1{DjY-Bh??1k$o`bvhi-JRodpA zaU@m^rdh#4M4g3GR}{|k)u1}<*bbR`j(DI?i}Ssd{&a_M7WixA>~yX;iZ5r;!}XDJ zO2x|cV}iDH$?}qFrQb^TO}+I> zsbKfrTIq-(K*+OWRO!*?-tpzM^taP~oiT;xC(;(%=iv7k4ckC&n?;#@loamD7;LK} zZfm7X;mEX*b9hm?acWIPCH_V~64=G(lRipbCaw*H_?%QxRxV&K#_`6f_*)!<DrA2L<@o z`jPv2%Gl58yUZ$48X#L3zp(bvoPbOcU()=sqB=~I)SH0G8=IA82dTmp8=moU++t7dm~03->2x+a zW1wmLNUq#fl;|t#RRQ!=61{ramVI}B{$wh;WcpsEV)sMX;8TdulDn`yr4FLJjM9e6 z$rst7S#UbjL=%}$_varx7TeD=Vkq#ZZyB|w_gUm)0s-(6{E=MN*V<|Oww;+8BU^!; zw0HgvL4L8{iKy!&((R+_01kS3NI9;7mJ~mh@BCJrvtxOMi<4piQs)n8IJyMAkt~%t z{zAu#Gi8=drXzfZ19=iH&M#6v>dzNNOu!0Pt@B`j4gsS7INd|(#w#CPT=*sf?^s5Q zt72v5%KnrvjE9PMW16$~P)rWKH1-@p5?dPGlqsvs7b*5;sNG~~Z3PH79Z zvfN<@RScyEvcAdP-AP%RYHg}1dFNI&M~os9_{0*9m6@3%DwqeL ziQHmd-WSV*x$Y(HO9ZEBORoeOJ=B*j0Ettbn*>fzn9Pefi7v zgwF>FVh|a09{gPE3$IB?*whQ*k99k|qK>-X;6I#ez%a<&(5%_pzoc{yPhaZ^L(*fr zf)ib9d3k==M*w_D_Hl1JyhQBATDBk~onZX}&uw<)K+Fl2Ri)P0-sptIbUr`btVz)l z9k_dWMkuX(E0VjYOtxNjd1UC_rVo1iD>-E~bVL4#zW&qk7j-5Mi%Oy5Enu93AleqEY37RXXA0oC!5} zVrh22h)+{1q^=xKvB*6h+R#7mK=8Bd)3XcgM?=dc)mtH3 zX(>GldsWq`;of@0rDT367N#`{^UdE6|3w!Tx8YZDC+ktHNY+FDfr?4xb8Bh#T+jEog24@s-z0M$ocws)231Q0aQ&DsFeu7920FCg`o3a&%$f#HQ_Iucj7*nCK4y7M~03-(WUA1SZ!JY5!;3BvP>M6L$-}`0q zj~z3+Z+9Ct#Itk2aXXIKZ;36W4jR!43BOq)BReg{X)3rUSMcDb&}>t6zTT$A zlX75pqf4Vbo<&r&9olS2~9wS6tMxIr)sL4!C3PvTQZdhK_}Z)Y<--m zZ{jZ3q)Uo2nz-(|TC|!2ylO*C*qVn84)^nqsmZ?_BG68PH0lepV`}{ZHT$GN6wU-WPPlBiyiVc#z8ub;O6sV`h$AmU*i0(KD zV<}PkigLvX!|W~bC6x_UIl03t3Lljc@PpH7qm!b|^IGK<=gzO|iuUfp%$x|{olo{Y zMPZe-yp?#l_su&cv=e4uO9Y>E-w^uoXU+ZwS?1 zQ<9Jq)qG3TSf(ka3qfNbKwer)NnicqfE6p|V0O?z<-B)8=Jij4 zk6Hq5>VBed`b0U=qC{d});rhes2RkDC}k;PPmt#SWNwuBVU=-^-Ttl$P30KFFn1NFGIC&(B`A>?_NEB7GMBKDm~~e?%Rb32 zsyOEtl2~mArF6vK6eVKlA-M}M>9-p?)L;e&V0Jzk5t^LG=VDO8N=j%uCSYx`2c@6f zqwI}jGPhO}C|c2SC{O8Cg@j=$RcM9!D9p zzgTx9;n4@)>OQ{bH$FELO+*-te`TyyF2hkCOMUd~h!J$B^}0FCyR9P1%%VEF8|}I` zA1kcePR$?1>9@?{?RFCLb>8}Ti)59WQI>*j_={nkfm0g+zuL*MsFAJF=1%q(T;);jq))a$? zsvJ}W6B%u9K4UMUC_9ws4GFFS*I-fMesD^yvucc_aps^`w&ha8&So}PM^zV~{=u;V z{83SI(lS2;rlhm$)sw(S;%>=U0M{x4vNeskb{)DkJ-VQjBTe7fy-0YQo zTEFmJ#vcrmc}y{dA`&g~aQ?Olzl)9j z6RZx~G{tIg6Obz17(BrAzlGhu!^9_+ReW-^w;#a9O5v<#4bd@kUlnWJiH)EddHbIU z$HKwp8W~n+t5IUv|NbI5uk!EkyEN8U8m7O8=zUB^C0U^QXGz9{kfmz|>wUocXXSw( z7ZF3h$`W()%KWoRNYLm{PE>MEO8EcgPZ7%n;n8HDT=f6g7WgswPllDreu3dXOZbSG z9|d80twTcIe>wRFeQ5$=rU{{EiN?Q1v3N%g&1AE@P5Uq6{~J@R8mmq#Fa%#-7bG*L zDV1y0l{A8ccCIhfRLBIprS_PA6u~@VOeT+LLqOtXBC`LQp(iw#YUxb)l-=)eEdK>t z3W=o*0573UkxO2qox|a^gFM8yMYpgpuk^|IZSMkqY(M~vq7Flx9ErHNWL!qTI{`G5 zq7m)-spSPH@qq%|Mw0&?FLD?p9zIP;Kz?DED;zQ&SyCEIW>G6MuLMEHslLI1n}#a{ zDsDQGDQqH>Vtj!o%POBW-ljdb-}4K`89V^x>3#;^D;^Zr67epgiNMjpmD2B3>1uY# zZle1UWB=-aUBV_F?H{g^TOrLa6zK!lgoA5&+UkdEAZGDC3FV)1Z-YW$ zCo{{nEe`|i(VumK9OO{!7IJyzGT6c%uAh6H)x(#5C#7$8#tZ** zKF4t|p^7BqSn2;nuQnrWqLAGj9kadfTYrx%_=n>TLlO&tUpi;QtnYAfM3?cIw4y$j zE9C&2ns_F9QSj;5mfb(X4T>!o{`VFP?6{Qt2+wMK&S^K1`&|(YF|R9K?OPFc*hYy6 zlU}Qo!-RwJR$>Inz2$tLn1YHiZ3HhrpW6%N&dpVRu6THShK*tQO0@|}8oODA;(ay6 ze|Q9uOpmbGW#7zRf3RPpLR_oAeb%tMvNUQa_`%L&azU4SG+t`X^}m1*iAAVL+ZG~Z z%YIU)qZP)Gn-C7XU8qwrdNrDE7u%MAf+A!t)OzqaM{OjHE>pI~RBoSEUY^achehjR z&oPt4y@9VU1#UYS&FWw~?49%Gok1POI~_Ee<5a-aV}IoRsy#j~gGTJl$%=TG9_|))Wo4<;HK8Tf)zxo-e z2a^};tmRaPQt}RWWR#y}3Vt(#Fz%~aN>!RsRT)&{2j9|*F%r*GykfT-cRz0#YPJe@ zlnCi1GwFWloo#7qERc zIG^8L7$vbgO5tXf%i{V@!s`sQU551>kk2gEDyZhWnJ$@n+dEZ~cyn9i=6=erkqOib ztcC=xh2=AWiYNXWf#z@j!lFWMgL5D@L@0Es3qYy{4GMu~v3d-xBzH(Cso42na=MI- z_M~nBz~lezGn{oVk$X)Ux-XA4i~J z0`BJFr(4CXl2j^=QQl%<3Dd}DDlKIQJ;bgWj#2b1`NM>$+%NvZp@~+{b-V`)?T_Ko zt2#cz%N6?u^j$64fu_or8dQlydV;xpyToQPNnc-z;?b6jQsrm=D;R-Ce+1h(?rac{ zqLF;n@Y-;+4+44t$L?1BOpi`*fWdgPi##Hwydn1Nv}CS`MkrxmRAFEB4@De(g*_7QPsY;8K((A z%n*;$Q>S_S{_b>mMIO5sk)#6Evg9i;{mIP3@q$xr_cXTN$6EcNh#u(#>SBc>?lo_5 zxP<_OnjPsVG@G^dXUog^2_s8Qe*}-eQ=A}@+MNuddkw9Zy{vD%%P@~nJ!PlbG;|k8 ze(c1(U=(H|)7#GA25{PHq&#apySlpO`R8M)At{Hc z0u!E5L^G<3D=ls~Cke3s6+>e%5G{w$JBj)HExD&r98AQfg~+CRVo63GvBw^>2+f#& zPWC$Hjsr3ZM$tz-kmEBoDX&Kw$%=qSttEp1lF1no=Lf-=NC7UuKFA&{`%2D1AD8%VG{4 z?2nNqE+L@=L82*{n->!etZ!|JLh@>IM|GRa7|g~pm7yy+LbM{@(!GWU7XSBu0p!kt z9(_3|p1Hg>z6As%&6PtkNi98ghczD>-MV_(r_6?@OLM>1m>$j5C8f?2JQO>*%`jIW z38_N+tdf(hEH4*ZOi||tS>FfNiB4DB=ZL5zKQ8)rLf|*jC?!ND|I=K?prFz}%vchx zQDp@r)L*?dYY8GSIe8lRZN3zbrmwHB)n>^{UrdV&9Yv9OJkl3uEiCZ=F!z>GS#59h zsB}n5H%Ll%OCyp}(hbs$(%mhBv~+iOmvkfDjnXOoUz~G1=lB11KiqN0{i0)R_PgJA z@4eQuo@dVa%(c%#wAAFsNVFoYDo2v*h6>r=F&<*c)2>lt4>J(MC8+I%DMfIZk_|~Y zFj=SjnF5t7Rn8xIy=ZdbX!1vq>JLYamD8QUyf+>K>k>|h_%}5?C%;&(df7}89J|l! z7aJTV0pXhca4(qgYsN-?!I3GEPdSH-sk0Q#h&kN;$IVoM|L;04CrgFp#z>ownHDpp zMGjnxOuzoDv6a!@SxP@Igk2W~%|+=SyyWd})-$m0`afFNZh3r(XGoVtWb&MG~!1vx`-7$?UK%jpz@Y zhjM+pYWdyqd>gl2DM(e(n07hJ=C)RrmN3lL^bx`@hF3W!Szz#DB<1UEwQzmtpQ^}t z`Dya|ziZ#;Im_4nW!y-bi!9B3$$j!nDRpKzj>Os~kKe}5k+N@TG9c*HY*bzmdCwb( zXtuEWyXzDhZkj0TrDi$M1D?G-oMgYk!E|F5chWZ^!;KtP`1}4!@Q3M?Zrr`p&S+ZQ zZo*eqtA$p-+x`aJi&5!4+<&#rtLjvwWK%|^r5qyp8Yg8fm#l)Mp78JL)}%7@znFJ# zaeGd5B(*(eiV%K3w|C0%=9|esTPRalO8Vh4jiwqJ?2vur^-T^?A-TzQN5|FVF8%%e zU+!F@7ci*hOvMKk|4p0vL?B;?42%56)4mvpEqbNO$aJ&nH&ShMqKHydnn5sAR)AiB z>YF-tAJn1D?xi!(8*}?+el|KKhM;8Sm`S_QGYFCx_6yu7Tu`DA2uI@0AI^KCPyZ|k zMh;I`?|xowyY`Y;p)s^G^^Lt?y{Tt$Z1M}Egq7i?Nk4opvu>SQcg*kiBfgM_lMkXn zH2kcgc{m;IaGIX}U#*GjWh^U||EFvdg=CF*OQriYDoQUj81en+S@?#4?d6?+fP88@ zr>>8CcU6-D>5-G8Qy#%$#v@~@ef#~^w{Jw(6&n1NJ`Vqe_@`LHZ>xTc=S|H(#p#=3 z?zova=qH*ruAJ%56uZ%@=CX5<-n%Gq*!d$F6Q8&T@-rN7z8R-cZ9ILwvf`7N@M#qL zSW1H85L+j%o||8REHJH9=&sw>>St`g@R!0r4*}kXP((;1pJ!7^Gp2EEtn)27Wyu37?{E}rkhcjiDCzm3}O&Y*4HCZm~ zVLqEr2DA7){ebTw=WJ&*&sO7QLZt^y0KfQm~Z^#W{wiu{3pcFQ|T$?XfU`)xxT131`+dM?{)+Jg^o|1fzH5PiorWAAAlD&X%)ji+@W4vSja8LV>UN6$!rO zjrJF`H8zj5z-DJ0P+7YXk!3NUDC+6%md_dZaq;2u=UmFo`L!C6U$+{zhl6LT#ANR9 z*bYnG>16Sqa+~MtdpnKP2m-E2KwO$9&$(WAz#WZ8jefE!(aH(@V*CA?!*bReOr-ob z!;NMFpSyxC&p2$sWX2U8jJs#*u?NBnnWNUp5Jv4*8N=`4?^xQ5{uzn;6764(S`w|+ zeFmY|TU&d~);b$DS69=`-OhK>pVl>j%kLf1j*kyev|9COEf?Da=3b_zG%(PMs%B_V zK>E*WP6@q`8}X~}`}glOL>FF~tav70d-M70g?olPjBMcF3H> ztY7?sYZea+OZ@6drs!n-sW`@A+skyAlQoFyNsF&srlWm&a?+n+LOT36%HmT@3SbV5 ze5Qml3>!2VC|HzS2F=<6jh1Hn$vi)}SMSjLv7qrzr%ES<7|0F7>dou_I>7trr#;<0%t zUKGll*jO!8Q#I?sU$_3)({9-*?6N2;bnG^X4ftmY13X3&+6YY)*00vk2Jc3vS3mM?rg-^1j-eAZic}chzYwVaDnxN0AE-CjqP+o< zxf+rKVlGE^BA(_nxfFgWc*>U)#p-FxEz0vGZPfl)s&@@qIT!omvL6;IJgVk_di>id z^WmLMlN6{*d9JXu#HrsAW^DAL6nHE(?&10zRTsqU?S6qOT)w|`q(l_8uzEXOX)!ZW z$N&_dFRSrBrEz;E7|Wy?V7~FZJ*a~}5No>Je^3p0hy3gMeA1`daai|_R_paPUF?VT z&l?3UXU6749>?u67=ys%CG+uNsZh|Qx?^%uX$!`p`uPsx^zG$g=E}oWm}i^*{U0D; zlnP?OW2N=Wg+BAknP#p>Obc2 z@niVBYJq|Hr=d~7M=<~nD`X-POZxkh5svFJy^iZ0l&;BfImsxRu6A_q47PP2t3j4cpCc7m21#U{@X7vH}}_zA1%|}-Q4(Sdqhfs z>jb;aq2)jfH7cl4cRnUuu69-{{KcIE;#)Q(N{2dXrvlEjz5wXejWMwh5P}TV)NFpvD;2I)s(|V0^HIppd&_F`W|Gxb2wLorM$^66@a)}?8 z-VYu>Ch%==wVstx-B5NihvITNm&^6(XWRM(%KmW4vdPNm=(=|8t($6fy&xzMelfdJ zGbl0DCbs1WWmoc}_1E zZ-Fol6s%{GDQ4x9_Teu-tYewKO6mBE(=ECie5vFU+dZe#fx{)Ye7U8-ePMiY(8sUb zgosI#e$#=sEj@Sxv}9BIXcD=(IZG{WGaqpR{CD51ZyS=uekJ37?&oCla|&+&3a$di z&jevPYYI14#pS96)vPN}78*UPLMRwJL(!Rr~|2fZ5bkS6#Qcyha2km@WYqKz=2w@~ zoW?V1YaEgB_3Dr+`FZ=26$gE(+#c!?x-(to2iZB2{@B!PYKvP$W!uU3g=&i#YYuxH zau;-BZ6oGFoW##*+`_$|N%8=IFzEvYX9hQ_U}6sX;HIft#w+|hiRkcSR#)}1K6WAd z2k9r_C}FfFadQrZeG2YuQ@NJ}E1crG^~rh;Es|x@=*NW-Ij&mxWwmb@I^5LF){FHS z&B-esf=TQh5emJ!pYaQ>h7#kb7yfzA`%gG}#|);D|2GpRq+;T$AOg;h@EIGgkusP+ zZuTPM-Y@0KB)&~pL53aAlI45xH9TKFk-?d-?|yjy^eHIWJ>+f?QE zcI!qpB%L-S30%$P(h5#Q8z{ zH(U5y*ZQU+i_Cz6(QiCOAGkCO#B2dDr#$cHd_GPluf49SmtX`9*-xfT&f3t{7`SAGQXF9x61W%mowBn)J!i)vaTiazmRV*i) z%&G7W!L%dm#afSu4*bj6G_PX$!Sm(};Fq!^-{zTy2FtDoO)itH=DgO~Q>Zh^JC3+D_;5`3ji&m_#hG=bAze^(j)f;(6e&Mvra#0e2*;bF+*d5D-S|W3Ks5hN2 zkqSeUCU0cw=v0b@VyIBoS%j_h&YFWbex7wIyM(|EhowOGA1Wa%par%KNEtz~Ow> zu24OzvC+CWl33Z{29MZ%ZC$)3ii&7@~jr)eM#=LiP57RhNnn*$Bp6n%O_w>k%;da zJoTVT@#aHtknZd}d(}2OjuRL+$8pVOOn?K=&sX?;ey-*}iVNN#bZNq&lDsJq!QIeU zbFhya^ruGG*BUy5g9xca(@Ej5;0#6o8|w7g@%HAIqDu_39F{OM`yq6ft|Z8BnfES@ z<=2Jp44f+x(FWVV7%Y4!s6cZI5&d5e#?nd0bAQbX!RmrU(_naCNs+Q)l5Kv!hIB=) zMTm!Grb2_a&897(d4CVOe)V!O2*{a4N@hQ!XT;fy;$l0T3mXZaL;c21X(kz8*-0CN3QeXKXX;28|((PRP)`jGEY0Oqpy((R1278!Z6 zY_oqu9Jho1F9H`(eG;T}#5t6ZjETQe(80eyjZ`^b>d~%NDokd-`a3#B4Z|A+c*mfb zbKau8b*O}c@Ml^>Xab2F$DWd=TD7??UEI4-ADlozO ze_s)j5&gwKP2GQem(V2@$h_TjNz79IO?*kAU!X??%~6ZIM977H`Lgr6;oFzoe)Zae z)z!XH_)jC1H~(>&>atJevIk71%l({eGXzdI#XRLdPfcXig%VKEE-k0nsZNfk&3K$V zO|^sN5r6VQ5z;I`s}9msDwh7wSF;8`EoP8(J-{;P&kg-Kajsm8goMO=F`w-E_SPh9 z`hBTZ;=|n;YUlrWN|t}~4i~BU_CCp8r9>kj5S64*kdu)!?y0d7Xwb)2yvly$nS37z z$5ZNGUQPw}DsniCT7BWPF(qAHODT)jTWf1yECuHN>;J|D43f!E*H2A3IXNRk!z`wm z?<0{Qoe(1NI42A6=g$S|w;xAt%PNkCpc2rmb6J+t9Qk9WDbxr+BWW&?!RHBrUSt75 z0SZR~Bt?EdXUgKUpce<3+GLg*KW&-uXZ9@(M?AF}0(>oRch?kO(3)R33Xpw4uqe1f z4GudmRZ205iRD1_%oY#B-V+7{M<9SsvsQ>Xep>Llp67cuc|Ceg0nrz)?dmfQvqk^) z-!_AB4D6uz^&tKyQ+VPXz}>`$s6O_`P}57HC>|}he64SAx=KC$E}O{ug@!Q_n^8k1 zh0jY20I*qzTZ2{Hg>Q6p8s0xYT7IsFUY&qK%r6FnmFZ9YyD_7$^i<;^Z<(Rq2)z_&8E)_O_m zJvS0K&EnHgPgp#X{XpSA9*(N#yk6uALZBkmpg=A z>h`(2HEQ>tD`yzEP!1WWd(X|!SK`w3-8SV=-wG$< zN(Q$F#p6xKDgX2XWk}a~S z?1@x&zsZq-SLO%MmM5;LPWhJ|BbYz!4&q>fCOS79sC^V>@6giL%6F|P{J3|z<&RyC zb4cpe=6EnaOx9(!D8cCewEP~mTt&VxI+nq^%oC+?*q=R_YSYy{Jw8u2=m}K>Nz2!G zq<~V*y3`gGi+stL@8^IjqjXMz-;>p#(i5#~*I)b{SajZ0sso;Z=QTAzN zkDZ%7e2j>5C+X&!$787Lo9myGQHS5jW~SyNy*}MT*(}y~pbT(Raw=&CZvMU0g3(I< zl-h@=ewwew`v_$Dpb3??q1@XoK46Y(r93#auAh)MAj;R{xWnyS@fHVxnB8A>SY2KZ z4K#Co^;O>$ZWmsRrBM(`!}2&>?a*w!bMnAfkxS*xd6V(|SOA_$RKfHsOCoX?RnPbD zdxO8tH{a5#7iJ@R0N0hz0JS&UPZ+_r6Z7d$TpA^ZK*%s& zd(V|3AbJ+9|Me%pzKXjS0AOl*$dA&bVY;iEJ6EIy3$1ZxEL)R}q}$rQwz5!XQ)OaH zh&nbayu9pkuG5QDrbYWVRs(MM8yXs_7M0E}NS<(AGB5Y%ENUGY2t!ify)wO$b80{R za&)~tkSip1{(bD5^4dvY?xp-r-xuA9&VaBfpMD%&68R1h{%09vVO%zs6rNEu zPUD&4KkjULLa~)@%OiyGc5#Aj3ljbfxl#OJQKB5BUUEb4oI=WwI^SyS%@X0VUKCTx zliN|q4C%_MvtBYl|0GTn_$5uXOy@_aymZ%b&Kks@XEs2CH z@(J3r&X4RO2iE~>UUHVWBwjmrXjEqSt%jWm^LMGZY~|I2{H4D-{NSYIHC$!P&l;NC zaxc;P|5$~z|AZ!V2`w4o1h0|vM0pC%M#Gi9r8h6{aQ`^J^t@U(@{e$45E@t-U_&V=(QIvPWoG-{Sj^RE&%u9T7yo;~Pt|{tOs$1kL?}7f+i&Ob z88M*;U8ZXr{8_vK6+Qv?v_cke+XPdh%M?7LQvZVm8!?0T@_||JmCm2+0etf`Sgl9q z#d7%nj6rZEa3bLHkkmr|Oq-`=vj(g?Rh$a1{xMwn>oMZVfSdppDsKJHR{$bf&kFp+ zVp-*ZKg=}vun_FiBGKWU29`-uP{hJ~^oOBWbU z;$cu=u(PvMF0j84+xx$=mak7bs}OnKD?UEHn3581-pLRJl>crE3H+JO`^$I{p!m|Y zo38TB4Gay2RNyLb2nZ~yN7;uzF3>pK1yZ^U5mB4{dB*-imp}=}h*35UYynIU7q8!N zJX{^AoE{&i@39#x)Rm?nxRh(RxN>N;>66DYsYWg?F6FCNshs~TVwx<^5bYZtj>yc) zGMgz~Vh6@dn0R=S+XHq$69AZfML^7Qm;ziM99I1^_pKZM;N>=>%p#I z^%44kRI$CfDg*_0uGCqgToC=0`Whb{N zKfc*7NTnVbA0JQKyR;;_^@Z!-*ReYHgqOyYWkp0Fuf5N5U57vdw0$O6UkGYwNLK|I zG@8Ex3+k+iuCA^E{jS_yC^*T4Bo*Lx>b7?m@ciTM7}+HKA1`L`ur~GnB4xtr zjP{tSKlZb}-^gkg%1WxUoO^SBgQ{%yv@mE0xc`ny;b4JM6hc!1z3-n(8*apsb{+--&kr0MfKX%ReA*`xz>F7S;0nOR^E_MJd?L&b;0 z9|IExs%Cb#i<-!J^Uy&U;KS-^i`#CyyQ7SkbLcJd=_LGE?kMs>fYLQ z8u-IFg9%{xm&{lC@KjQ(^-25Y#`{-DDn!;@S%0#BxGbjhSF0*yP%Qf=Z20V?0CIEt z7ZgIqcKbUbSl0hxo<^C#dLqy^qcfuQ@09P$08?yh>^b#6NcmGzoT~d2R#q&SO#Vo$ zz}u4t$Lx0lVvPT*A;9&3iu0%@p6$OWWKZ+NC7pLlR{a|Ue0JhN%DrnHSqxLgtA*$2iRg2FOW>3U{va%8E3`Ng>pNT^&uc#n%bAtnE69qx?x4prE z#FE<0pGGnKFPAI(1EP%o=-`5>y-gy!Dtz5O%5gmD5VpF{;{G?@m{Fi%V5EE}uXp>T)4*?zOlOx;!){1o$r)ANp&%IC5lC*voWy8pu%ZmuME~KwkYE zB+|^&aZHsk(lJyV7kiUIsQ@ePGv4CZyU44~tSGpy)u1#@ThjZ zz;ARsNGicRYF7HT>~W>txy%kA)XX%`8_CXruZ*&Itj5{8UiYz=hf5Y=y1*A#z)S3@ zxBv;WVue8uyx_{G?IFDRs-1T?S1Vptc7~@$LkZ(rr4myaq7t7&eQ;zud;>bWpW5fA zIApwWbe*w(-OO8*oeWlUT^tE|Km6W6EwfDdFxa}5Hjm&UZoS-`DDdd6TwE+6HUO|v zcMU*#4JVv@2>+2|Iahh!`J8DCguva29O-eL$lz71g8+wmGS6*tDi=SUv-%HK6-b>s(+#Veqemq&H zq*csjrj+{@44#KE&SkNu0whiZLzCWvyVN%dme=)<-o&i^u4lts{vUy*gr<~FP!1rI zUdWB2x15deonOp2n1M8Gs@hCRYM3n?{6zVK`A?QP*3;xx3Hr-X)5W zehlh@=cCPxTJ@&V7#P%hlX*CbKlfUSR7$9RbANB^yAaY&qdci?luPD}Rm_nQ2Zq}p z=a3|0C^9JS;@Qm<MM<~-NyGVo|~+<=-jKG>)i;z{9et!@GGM> zuSi;mnaf^Iyy?*Hc(^zraESDw;Q>X84EdYzax7#-NSW$^csdEQiR?9&BVUGn_1EBG z$uejHz>LhsVcT6?*W)5~@#2T7Zbgd>2Bnl(EZzIe!FZ-K>C%j$yPeTw3GfJOdL%N9 z`lA*4qbYZrj%wc$j|lKO?TG-ZDqv0_h$(`@&zzu9YraKqUfiYvKs4OMoL*?=WyKlp zyROmE9)v{F`cAWj%LC|M7!-i zP}Qd6BD9*dNEXp1vk|D1xw1HrI8(I@9@BL;*+&EmZ1PVx4xG&;8zzAW%gSl7#SL{0 zRT0RT#*>7bpwC`;>N%Y`t%Q6Z&hDY#A6aUeYF2lsgGRXqnMCaO$a~+@?S5&9IY=6% z+;Dy&}qS{<);e5IQi#N?7AY;?TmeJbzP-$FkC<0PM<+WT4PPnU~4WZBas$a1Otc*w-; zY6F4%rCJTNsEZHxx3bv8L_41M90!I@c3b^loSr4Z1vy_BTGi67-CBg~TBTzcs5^y=r2*WFhk_qUfyGu8@4tzMwBJG(_b2$bwn6#~Ckp{Bd1GspSqU=oiea@dl; zJs)^3`0j9V(HPb2SFP2ATrP3LwJU%hXgDll)Fh;XP)TyFml_umkM#q=(c~e$aa6r! zR1{*dw#Ns)2>c7452L9mFKll`yMP%yn6ZF+jz(>5f2{}d7<(cTkK4TSqsMCP-{ zyR%W{^`#eDkpO+KTbWO!*7v**_fStSY5nvIo6Kvdy(GybCar2C9B*=OdpH>rb7}+| z3CEvw+BS>PRvI43a%i-}?Q7J+3ts_#_usZCw{vk+%`sb+2HKV91mjWUrPY!pN#~vs zNt{*=&ZGDfYw87>btp@%9?8^}7j~}(VCKCZp%<;`fh~M47>jd<3gNOwDGiU7^VJ~( zUtf_Q-1Z(WEjk7E`M*HVT6ZDh)+6PLgjn~}ZC?^?% zG1Q;Mm~>nDYpj=&K)o$7*sA{KiCB6;xs0V~a!SB$>pk?qoKCM+?#=1dqz-Fr;k?}J z;!p4X*oAtxN==o`f{F#JXZm=|T&CIP6MfdTZ7P+_`T?=6yJ|9Z$<6dW5SdkNLwuEn zR24WCmfwRu-)TmvQ_Ow@sIj85Rw=K;`PVNK5wCbIO-^$lnYabj!ut5?UTAZ!AKPgE!W4V8;dFb{gPXua6BU=%pxf8$}Zy~3G z($Oy3`&azP#Bx4CX-I$Ar1NPpnMF17jj7nr-ORU)OrXtEaURCX*RfVAI$Lt{VQeaW z$?<4T)HzW9lzn~g%zT}X&$n4z<>jsQ>E^@XJeGi&DV+>`Bq7hh{iV3ew2+4ROsUl% zvEA`qN3YG`m7x6haJxPT*wc(fOH&v&P0xs(l&jfS-bZG*{1Q5D{v&tj4GHaP?DA` zJ@)>v`SV9q4>A?f%+63^7t{2-NGJa|_BTN$7eJ zY%z$RHzEOu0prX)A!iA{+6Jl^Ow*GpsL&u~<*ST`^T{k;P{}2Uk$BF&Ev)^LbtU)#i+@6GCEVyZ$FvrcEQVF3{3V8FAo8a@hXer_#2| zad^#0aCC9L>Sl>HFdx}OF!V@m0ik36wj|R=J!@`+Wp*@$PqpyJl1S>uCOvjwt_8nq20APW-3vw zD?OJ$Y9uYXRco?fFVb$DjrnMr#}Wm=)MQSEyu2$1ET(3Y;q0c5Cz(j)3CynL=pF&*$U|0+*BOq=z8<=_}!>QyiX@9yY zS%Il{x?8xc|H5Sth7yc5TV@hYi{)>i^zGfA7+|$kDb)($)8JH3G#d&_K)?eQio|L| zF1V%{5We#S+oi`80v7S42`nOG1^R(%4fX-OXvwzW3zpU&g`w6FR$~kDZMN4o|94&VG%9@f9{%FZ6oO{ znajG7(k9phK19;b@Qu!o=M}t)Bs{LlQ%AVxvdM2L9@JWHDsua^*d!!G^hzZ*cS}pv z{8{~FV{n;C>(lUPimzr{?<*2FSy4%isfgOC^!lR6{7S~Rn$;@ma*%~{k69m6Ibc;- ztZZ7a=NwF}$;O_EvOUA#fX0Ui{;}wXV=v~Os~0Gd<{ZyKnjo)i_~N+%XkUNPPe77D z^YMf3xN?C~|Ct`7V)+}cLJIKS7vVMs(C8f)s3JA%0@V;~kHTbVhr3x`j{cgYcLGah zW}*9-t!|sAXk}MJo0mIJ@0oF`biEQT&&P!O2erX)r^X9y)6TSQ^dkeAn{Np8 z2*@euCZkP7J6L9`D-larFDe{yMibFxCWut=_}oyFf0sr&9WH)JXE&RacOhn*E+^<` z@w5>-TxnvG_yrd%uxTb2TznPQ?*LwIxPUupI7#<6o~oWHD3K2;A-R$M(`o#VS}4Asp@fJjR|;!aPU z1|Hl)F3rw%l=Rl$>h-5CoI`L!NTd`c!g0-ZsC{gsT7m~cVyHW1D;;)5nC<*Y*<8w7 zn}>K*NHh=#O9>)O6T&n1I?qPi^qQ1_nUv+ubPS&paa%Bpyj}+c1!Enu`(5MJu6_jL z4jgJ=lwF&%=ec*tjS`6-u0z6c$qVX_3jS6T2765FnoBwHJ&@yWa$dnM9>p5Ze)t;+~y-f=@54C}U(mS8s7OIqs)YNcHP#K40nZ@;UD}(Ok zNNoamzpWDvW|JLDS0M6k$1!NqXx3Wpw{5u_2mG!#Ia%-75{IJx*k_eGtTzqA5PxBF zbr^L^K*Fyo;MnW(iWhq1=bQ3(Xq)V}K0>R~fk9ldNmHjpC}Z1OVb34$Fr;u3DAWhN z9e(GDD-VTDY4e)c9UB-%?Q_;a=LTCCj~|{^2Y&i_j*yI!H%XoLv05jx(t-QLYpNQ7 zNYL?Hd8`ziPAm+GfP@di82p`_^)e#M&Nt&;BP2%B)5wJ6MqmCc!N9sXYFIk7#XK^L z>Ec++FdQc1!Q}*bOB{agL_Vj~Zu5z3gbxO?MJxmx20E83{!FvE5y{;F5YImr3phv4 zJ@y(+nr0R2U?%iZlD9(@3qM}=W zr`NR0veRz$opjBPHQQ^*I})FXVi5>$nzMwOV5I%+Vr-F6kK8`#panV6U^llfK^|d` zZ#KQ%-G9CRvhaWv6WX|w%NwVY6t&(?NG7dZd>fmqqeG|AuZ3ogJ&b% z-$!OMb-48Mqu+Y37KK&(!yCy^mo}Sds~m!s^H@w<@S(oO6dei@621tUTJD=r!nk@4 zHWOj{Pl2-_tV9LMu-Q>W-yb@&qNpPk%szTKN@6hEmd)@LFD@l}-fQcR5OG@O?|Xry zTaI(3ZhT*G_c!pbt&Nz1p%WJV?s<3OrARneAA1XmgP0y3qJ%CIb4aE_7S@eHJp1_~ zRi+MtY}lgFF`N?nP%b4{AJI05xvE+`on=y1b=IKjtn0(W9P42M4^(^~4GxQtck{Cq zhDQ3{9Ff#Jq{YlD?OzC?hVHJPTlPQPSfrVfhczpR$rM<*3_{SI)QBU!<|%*lmWdOB z1-TH}?E)Em3^F~w&zWF3+7+bq`qMndiag%sLubKAuAa^+vNsSCO~&<2eCdM7QzzG1 ze9fIIvFY+j#^N4DiC$%~(;XS$7TNAs)iz>1P5r=q-Z@~VbecbPn`b$s{dtkrco0Zk zgXL0J7mzxnOeEC1!Wx;!dk5yDo0TAIYdggtADUD9-lRec<4=__8Ny&EX4|gEZ%gVN zuH$+gRq_jT+Tegj7*oDOgirzGOhlLb64@|Qfkdgapb3Dle(~vcS0j{su0G^k|1f0xt}X2U5k4EFuJEMb4}*`K+CaZn}BiBOC;%e zFUpE=-@ZxajX>(oeF7)QS(Og6P@orEjUIz)+w&0EK7psK63&v`t3qXp6U8&NAz5uZ ziw#Bhip8Zu&UXwaj$T`Y!(s~4AiPpUG$Q0j*H|s{Rxi5eM{QoGxYu|mM4DN5M_!K@ zitwG`2a?_xeblxrpH4Q_MFAhYiPry4b<6TcVUA3;%q6o;zkm2(xW}4I>@fOC`FK4# zf76;k8C7Mnq19!qYt^nHG7FW{oi40FvE!8(s2VDlyRdXfZ%gNYR5P5qxIY9_*OFP8 zM!vANnvu69$*YL#zIHjo9`4~a6sf2r@h$OxnW^7d1@?ukDg0pyTCJ$~cnm(-Xzg~+RlF6!qF~C3wYTa_Qw-&5%2gU z%3)8-XMZ@rBBuGZYBv0w?c~<7mc_phAN&0dvt3dU(xxJOoapu}F*H7J6qhMGAq*9N zLfk6*;gAhtuF_EH)#3f%X=T+YjIj!mYwzbY=a**%9@g(Jk=ZmX>3n!Kt{7b_7Y(Se z*guZhDitWv|T+q_An|&$U0u6r|2Ly zM!=vn66xJeqQT_*E^d4eƠe_9~>d~YSf`!i&Yc4QvzIbrC_&PWr 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%0D=UsO7zySxvd<9Vs>63nUZp`@Z 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 Xh4zDTmAyzR@Y1}kqgHy$>e>GQZH&(Z literal 0 HcmV?d00001 diff --git a/tests/test-mdxcanvas-payload.json b/tests/test-mdxcanvas-payload.json new file mode 100644 index 0000000..abb37ab --- /dev/null +++ b/tests/test-mdxcanvas-payload.json @@ -0,0 +1,826 @@ +{ + "deployed_content": [ + [ + "module", + "unit-1", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "unit-2", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "unit-3", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "unit-4", + "https://byu.instructure.com/courses/20736" + ], + [ + "module_item", + "unit-3|Week 1 Wrap-Up", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "module_item", + "unit-4|day-9-header", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "assignment", + "lab-notebook-week1", + "https://byu.instructure.com/courses/20736/assignments/1332409" + ], + [ + "module_item", + "unit-3|lab-notebook-week1", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "quiz", + "day-9-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609750" + ], + [ + "page", + "day-9-marine-mammals", + "https://byu.instructure.com/courses/20736/pages/day-9-marine-mammals-10" + ], + [ + "module_item", + "unit-1|day-1-header", + "https://byu.instructure.com/courses/20736#module_381629" + ], + [ + "module_item", + "unit-4|day-9-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "module_item", + "unit-3|day-6-header", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "quiz", + "day-1-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609751" + ], + [ + "module_item", + "unit-4|day-9-marine-mammals", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "quiz", + "day-6-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609752" + ], + [ + "assignment", + "group-presentation", + "https://byu.instructure.com/courses/20736/assignments/1332413" + ], + [ + "page", + "day-6-coral-reefs", + "https://byu.instructure.com/courses/20736/pages/day-6-coral-reefs-10" + ], + [ + "page", + "day-1-welcome", + "https://byu.instructure.com/courses/20736/pages/day-1-welcome-to-the-sea-10" + ], + [ + "module_item", + "unit-3|day-6-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "module_item", + "unit-4|group-presentation", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "module_item", + "unit-1|day-1-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381629" + ], + [ + "module_item", + "unit-3|day-6-coral-reefs", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "module_item", + "unit-1|day-1-welcome", + "https://byu.instructure.com/courses/20736#module_381629" + ], + [ + "module_item", + "unit-4|day-10-header", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "module_item", + "unit-2|day-4-header", + "https://byu.instructure.com/courses/20736#module_381630" + ], + [ + "quiz", + "day-4-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609753" + ], + [ + "module_item", + "unit-3|day-7-header", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "page", + "day-4-phytoplankton", + "https://byu.instructure.com/courses/20736/pages/day-4-phytoplankton-and-primary-production-10" + ], + [ + "module_item", + "unit-1|day-2-header", + "https://byu.instructure.com/courses/20736#module_381629" + ], + [ + "quiz", + "day-10-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609754" + ], + [ + "module_item", + "unit-2|day-4-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381630" + ], + [ + "quiz", + "day-7-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609755" + ], + [ + "page", + "day-10-sharks-rays", + "https://byu.instructure.com/courses/20736/pages/day-10-sharks-rays-and-sea-turtles-10" + ], + [ + "page", + "day-7-estuaries-mangroves", + "https://byu.instructure.com/courses/20736/pages/day-7-estuaries-and-mangroves-10" + ], + [ + "module_item", + "unit-4|day-10-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "module_item", + "unit-2|day-4-phytoplankton", + "https://byu.instructure.com/courses/20736#module_381630" + ], + [ + "module_item", + "unit-3|day-7-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "quiz", + "day-2-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609756" + ], + [ + "page", + "day-2-ocean-geography", + "https://byu.instructure.com/courses/20736/pages/day-2-ocean-geography-10" + ], + [ + "module_item", + "unit-2|day-5-header", + "https://byu.instructure.com/courses/20736#module_381630" + ], + [ + "module_item", + "unit-4|day-10-sharks-rays", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "module_item", + "unit-3|day-7-estuaries-mangroves", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "module_item", + "unit-1|day-2-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381629" + ], + [ + "quiz", + "day-5-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609757" + ], + [ + "module_item", + "unit-1|day-2-ocean-geography", + "https://byu.instructure.com/courses/20736#module_381629" + ], + [ + "module_item", + "unit-3|day-8-header", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "page", + "day-5-zooplankton", + "https://byu.instructure.com/courses/20736/pages/day-5-zooplankton-and-food-webs-10" + ], + [ + "module_item", + "unit-4|Final Assessments", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "module_item", + "unit-2|day-5-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381630" + ], + [ + "assignment", + "lab-notebook-week2", + "https://byu.instructure.com/courses/20736/assignments/1332420" + ], + [ + "quiz", + "day-8-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609758" + ], + [ + "module_item", + "unit-1|day-3-header", + "https://byu.instructure.com/courses/20736#module_381629" + ], + [ + "module_item", + "unit-4|lab-notebook-week2", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "page", + "day-8-deep-sea", + "https://byu.instructure.com/courses/20736/pages/day-8-the-deep-sea-10" + ], + [ + "module_item", + "unit-2|day-5-zooplankton", + "https://byu.instructure.com/courses/20736#module_381630" + ], + [ + "module_item", + "unit-3|day-8-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "quiz", + "day-3-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609759" + ], + [ + "assignment", + "final-reflection-essay", + "https://byu.instructure.com/courses/20736/assignments/1332423" + ], + [ + "quiz", + "midweek-quiz", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "page", + "day-3-seawater-chemistry", + "https://byu.instructure.com/courses/20736/pages/day-3-seawater-chemistry-10" + ], + [ + "module_item", + "unit-1|day-3-prep-quiz", + "https://byu.instructure.com/courses/20736#module_381629" + ], + [ + "quiz_question", + "midweek-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q12", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q7", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q13", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q10", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q8", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q9", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q11", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q14", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "day-10-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609754" + ], + [ + "quiz_question", + "day-10-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609754" + ], + [ + "quiz_question", + "midweek-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "midweek-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "day-10-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609754" + ], + [ + "quiz_question", + "midweek-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "day-10-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609754" + ], + [ + "quiz_question", + "day-10-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609754" + ], + [ + "quiz_question", + "day-10-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609754" + ], + [ + "quiz_question", + "day-10-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609754" + ], + [ + "quiz_question", + "day-9-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609750" + ], + [ + "quiz_question", + "day-9-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609750" + ], + [ + "quiz_question", + "day-9-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609750" + ], + [ + "quiz_question", + "day-9-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609750" + ], + [ + "quiz_question", + "day-9-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609750" + ], + [ + "quiz_question", + "day-8-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609758" + ], + [ + "quiz_question", + "day-9-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609750" + ], + [ + "quiz_question", + "day-8-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609758" + ], + [ + "quiz_question", + "day-9-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609750" + ], + [ + "quiz_question", + "day-8-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609758" + ], + [ + "quiz_question", + "day-8-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609758" + ], + [ + "quiz_question", + "day-8-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609758" + ], + [ + "quiz_question", + "day-8-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609758" + ], + [ + "quiz_question", + "day-8-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609758" + ], + [ + "quiz_question", + "day-7-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609755" + ], + [ + "quiz_question", + "day-7-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609755" + ], + [ + "quiz_question", + "day-7-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609755" + ], + [ + "quiz_question", + "day-7-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609755" + ], + [ + "quiz_question", + "day-6-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609752" + ], + [ + "quiz_question", + "day-7-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609755" + ], + [ + "quiz_question", + "day-7-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609755" + ], + [ + "quiz_question", + "day-7-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609755" + ], + [ + "quiz_question", + "day-6-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609752" + ], + [ + "quiz_question", + "day-6-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609752" + ], + [ + "quiz_question", + "day-6-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609752" + ], + [ + "quiz_question", + "day-6-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609752" + ], + [ + "quiz_question", + "day-6-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609752" + ], + [ + "quiz_question", + "day-5-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609757" + ], + [ + "quiz_question", + "day-5-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609757" + ], + [ + "quiz_question", + "day-6-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609752" + ], + [ + "quiz_question", + "day-5-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609757" + ], + [ + "quiz_question", + "day-5-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609757" + ], + [ + "quiz_question", + "day-5-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609757" + ], + [ + "quiz_question", + "day-5-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609757" + ], + [ + "quiz_question", + "day-5-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609757" + ], + [ + "quiz_question", + "day-4-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609753" + ], + [ + "quiz_question", + "day-4-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609753" + ], + [ + "quiz_question", + "day-4-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609753" + ], + [ + "quiz_question", + "day-4-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609753" + ], + [ + "quiz_question", + "day-4-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609753" + ], + [ + "quiz_question", + "day-4-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609753" + ], + [ + "quiz_question", + "day-4-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609753" + ], + [ + "quiz_question", + "day-3-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609759" + ], + [ + "quiz_question", + "day-3-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609759" + ], + [ + "quiz_question", + "day-3-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609759" + ], + [ + "quiz_question", + "day-3-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609759" + ], + [ + "quiz_question", + "day-3-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609759" + ], + [ + "quiz_question", + "day-3-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609759" + ], + [ + "quiz_question", + "day-3-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609759" + ], + [ + "quiz_question", + "day-2-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609756" + ], + [ + "quiz_question", + "day-2-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609756" + ], + [ + "quiz_question", + "day-2-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609756" + ], + [ + "quiz_question", + "day-2-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609756" + ], + [ + "module_item", + "unit-4|final-reflection-essay", + "https://byu.instructure.com/courses/20736#module_381632" + ], + [ + "module_item", + "unit-3|day-8-deep-sea", + "https://byu.instructure.com/courses/20736#module_381631" + ], + [ + "module_item", + "unit-2|midweek-quiz", + "https://byu.instructure.com/courses/20736#module_381630" + ], + [ + "module_item", + "unit-1|day-3-seawater-chemistry", + "https://byu.instructure.com/courses/20736#module_381629" + ], + [ + "quiz_question_order", + "midweek-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609760" + ], + [ + "quiz_question", + "day-2-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609756" + ], + [ + "quiz_question_order", + "day-10-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609754" + ], + [ + "quiz_question", + "day-2-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609756" + ], + [ + "quiz_question_order", + "day-9-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609750" + ], + [ + "quiz_question_order", + "day-8-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609758" + ], + [ + "quiz_question", + "day-2-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609756" + ], + [ + "quiz_question_order", + "day-7-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609755" + ], + [ + "quiz_question_order", + "day-6-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609752" + ], + [ + "quiz_question", + "day-1-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/609751" + ], + [ + "quiz_question", + "day-1-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/609751" + ], + [ + "quiz_question_order", + "day-4-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609753" + ], + [ + "quiz_question_order", + "day-5-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609757" + ], + [ + "quiz_question_order", + "day-3-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609759" + ], + [ + "quiz_question", + "day-1-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/609751" + ], + [ + "quiz_question_order", + "day-2-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609756" + ], + [ + "quiz_question", + "day-1-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/609751" + ], + [ + "quiz_question", + "day-1-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/609751" + ], + [ + "quiz_question", + "day-1-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/609751" + ], + [ + "quiz_question", + "day-1-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/609751" + ], + [ + "syllabus", + "syllabus", + "https://byu.instructure.com/courses/20736/assignments/syllabus" + ], + [ + "announcement", + "welcome-announcement", + "https://byu.instructure.com/courses/20736/discussion_topics/686882" + ], + [ + "quiz_question_order", + "day-1-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/609751" + ] + ], + "content_to_review": [], + "error": "" +} \ No newline at end of file diff --git a/tests/test_discord_limits.py b/tests/test_discord_limits.py new file mode 100644 index 0000000..c40a17d --- /dev/null +++ b/tests/test_discord_limits.py @@ -0,0 +1,80 @@ +from notifications.discord_limits import ( + TITLE_LIMIT, + DESCRIPTION_LIMIT, + FIELD_NAME_LIMIT, + FIELD_VALUE_LIMIT, + FIELDS_PER_EMBED, + FOOTER_LIMIT, + AUTHOR_NAME_LIMIT, + EMBED_CHAR_LIMIT, + MAX_EMBEDS_PER_MESSAGE, + CONTENT_LIMIT, + calc_embed_size, + split_content, +) +from notifications.resources import Author, Embed, Field, Footer + + +class TestConstants: + def test_embed_char_limit(self): + assert EMBED_CHAR_LIMIT == 6000 + + def test_field_value_limit(self): + assert FIELD_VALUE_LIMIT == 1024 + + def test_fields_per_embed(self): + assert FIELDS_PER_EMBED == 25 + + def test_content_limit(self): + assert CONTENT_LIMIT == 2000 + + def test_max_embeds_per_message(self): + assert MAX_EMBEDS_PER_MESSAGE == 10 + + +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 + + +class TestSplitContent: + def test_short_content_unchanged(self): + result = split_content("hello", 2000) + assert result == ["hello"] + + def test_long_content_splits_on_paragraphs(self): + paragraphs = ["paragraph " + str(i) for i in range(200)] + content = "\n\n".join(paragraphs) + result = split_content(content, 100) + assert len(result) > 1 + for chunk in result: + assert len(chunk) <= 100 diff --git a/tests/urgent-mdxcanvas-notification.png b/tests/urgent-mdxcanvas-notification.png new file mode 100644 index 0000000000000000000000000000000000000000..4eddeb36aeb3f828a63ea84c73cd6199e97cdb58 GIT binary patch literal 61376 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 Date: Wed, 8 Apr 2026 12:41:27 -0600 Subject: [PATCH 31/43] Add EmbedBuilder to discord_limits.py with tests EmbedBuilder tracks character budget and field count, enforcing Discord embed limits when building Embed dataclasses incrementally. Co-Authored-By: Claude Sonnet 4.6 --- notifications/discord_limits.py | 163 ++++++++++++++++++++++- tests/test_discord_limits.py | 224 +++++++++++++++++++++++++++++++- 2 files changed, 385 insertions(+), 2 deletions(-) diff --git a/notifications/discord_limits.py b/notifications/discord_limits.py index 847ef46..a9098d0 100644 --- a/notifications/discord_limits.py +++ b/notifications/discord_limits.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .resources import Embed +from .resources import Author, Embed, Field, Footer, Notification, WebhookMessage # -- Discord API limits ------------------------------------------------------- TITLE_LIMIT = 256 @@ -52,3 +52,164 @@ def split_content(content: str, max_chars: int = CONTENT_LIMIT) -> list[str]: chunks.append("\n\n".join(current_parts)) return chunks + + +# -- EmbedBuilder ------------------------------------------------------------- + +class EmbedBuilder: + def __init__( + self, + title: str, + description: str, + color: int, + timestamp: str, + author: Author | None = None, + footer: Footer | None = None, + ): + self._title = title + self._description = description + self._color = color + self._timestamp = timestamp + self._author = author + self._footer = footer + self._fields: list[Field] = [] + + self._base_size = len(title or "") + len(description or "") + if author: + self._base_size += len(author.name or "") + if footer: + self._base_size += len(footer.text or "") + self._fields_size = 0 + + def remaining_chars(self) -> int: + return EMBED_CHAR_LIMIT - self._base_size - self._fields_size + + def can_add_field(self, name: str, value: str) -> bool: + if len(self._fields) >= FIELDS_PER_EMBED: + return False + field_size = len(name or "") + len(value or "") + return self._fields_size + field_size <= self.remaining_chars() + + def add_field(self, name: str, value: str, inline: bool = False) -> bool: + if not self.can_add_field(name, value): + return False + self._fields.append(Field(name=name, value=value, inline=inline)) + self._fields_size += len(name or "") + len(value or "") + return True + + def build(self) -> Embed: + return Embed( + title=self._title, + description=self._description, + color=self._color, + fields=list(self._fields), + timestamp=self._timestamp, + author=self._author, + footer=self._footer, + ) + + +# -- MessageBuilder ----------------------------------------------------------- + +class MessageBuilder: + def __init__(self, color: int, timestamp: str): + self._color = color + self._timestamp = timestamp + self._content: str | None = None + self._embeds: list[EmbedBuilder] = [] + + def set_content(self, content: str): + self._content = content + + @property + def current_embed(self) -> EmbedBuilder | None: + return self._embeds[-1] if self._embeds else None + + def new_embed( + self, + title: str, + description: str, + author: Author | None = None, + footer: Footer | None = None, + ) -> EmbedBuilder: + eb = EmbedBuilder( + title=title, + description=description, + color=self._color, + timestamp=self._timestamp, + author=author, + footer=footer, + ) + self._embeds.append(eb) + return eb + + def build(self) -> WebhookMessage: + return WebhookMessage( + content=self._content, + embeds=[eb.build() for eb in self._embeds], + ) + + +# -- validate_notification ---------------------------------------------------- + +def validate_notification(notification: Notification) -> list[str]: + violations: list[str] = [] + + for msg_idx, message in enumerate(notification.messages): + prefix = f"Message {msg_idx}" + + if message.content and len(message.content) > CONTENT_LIMIT: + violations.append( + f"{prefix}: Content length {len(message.content)} exceeds {CONTENT_LIMIT}" + ) + + if len(message.embeds) > MAX_EMBEDS_PER_MESSAGE: + violations.append( + f"{prefix}: Embed count {len(message.embeds)} exceeds {MAX_EMBEDS_PER_MESSAGE}" + ) + + for emb_idx, embed in enumerate(message.embeds): + ep = f"{prefix}, Embed {emb_idx}" + + if embed.title and len(embed.title) > TITLE_LIMIT: + violations.append( + f"{ep}: Title length {len(embed.title)} exceeds {TITLE_LIMIT}" + ) + + if embed.description and len(embed.description) > DESCRIPTION_LIMIT: + violations.append( + f"{ep}: Description length {len(embed.description)} exceeds {DESCRIPTION_LIMIT}" + ) + + if embed.footer and len(embed.footer.text or "") > FOOTER_LIMIT: + violations.append( + f"{ep}: Footer length {len(embed.footer.text)} exceeds {FOOTER_LIMIT}" + ) + + if embed.author and len(embed.author.name or "") > AUTHOR_NAME_LIMIT: + violations.append( + f"{ep}: Author name length {len(embed.author.name)} exceeds {AUTHOR_NAME_LIMIT}" + ) + + if len(embed.fields) > FIELDS_PER_EMBED: + violations.append( + f"{ep}: Field count {len(embed.fields)} exceeds {FIELDS_PER_EMBED}" + ) + + for fld_idx, field in enumerate(embed.fields): + if field.name and len(field.name) > FIELD_NAME_LIMIT: + violations.append( + f"{ep}, Field {fld_idx}: Field name length {len(field.name)} exceeds {FIELD_NAME_LIMIT}" + ) + if field.value and len(field.value) > FIELD_VALUE_LIMIT: + violations.append( + f"{ep}, Field {fld_idx}: Field value length {len(field.value)} exceeds {FIELD_VALUE_LIMIT}" + ) + + total = calc_embed_size(embed) + if total > EMBED_CHAR_LIMIT: + violations.append( + f"{ep}: Embed char total {total} exceeds {EMBED_CHAR_LIMIT}" + ) + + return violations diff --git a/tests/test_discord_limits.py b/tests/test_discord_limits.py index c40a17d..eb714a6 100644 --- a/tests/test_discord_limits.py +++ b/tests/test_discord_limits.py @@ -11,8 +11,11 @@ CONTENT_LIMIT, calc_embed_size, split_content, + EmbedBuilder, + MessageBuilder, + validate_notification, ) -from notifications.resources import Author, Embed, Field, Footer +from notifications.resources import Author, Embed, Field, Footer, Notification, WebhookMessage class TestConstants: @@ -78,3 +81,222 @@ def test_long_content_splits_on_paragraphs(self): assert len(result) > 1 for chunk in result: assert len(chunk) <= 100 + + +class TestEmbedBuilder: + def test_build_produces_embed(self): + b = EmbedBuilder( + title="Title", + description="Desc", + color=0xFF0000, + timestamp="2025-01-01T00:00:00Z", + author=Author(name="Bot"), + footer=Footer(text="Footer"), + ) + embed = b.build() + assert embed.title == "Title" + assert embed.description == "Desc" + assert embed.color == 0xFF0000 + assert embed.author.name == "Bot" + assert embed.footer.text == "Footer" + assert embed.fields == [] + + def test_add_field_success(self): + b = EmbedBuilder( + title="T", description="D", color=0, timestamp="", + ) + result = b.add_field("Name", "Value", inline=False) + assert result is True + embed = b.build() + assert len(embed.fields) == 1 + assert embed.fields[0].name == "Name" + assert embed.fields[0].value == "Value" + + def test_can_add_field_checks_char_limit(self): + b = EmbedBuilder( + title="x" * 5000, description="", color=0, timestamp="", + ) + assert b.can_add_field("name", "x" * 1500) is False + + def test_can_add_field_checks_field_count(self): + b = EmbedBuilder( + title="T", description="D", color=0, timestamp="", + ) + for i in range(25): + b.add_field(f"f{i}", "v") + assert b.can_add_field("f25", "v") is False + + def test_add_field_returns_false_when_full(self): + b = EmbedBuilder( + title="x" * 5900, description="", color=0, timestamp="", + ) + result = b.add_field("name", "x" * 500) + assert result is False + assert len(b.build().fields) == 0 + + def test_remaining_chars_decreases(self): + b = EmbedBuilder( + title="Hello", description="World", color=0, timestamp="", + ) + initial = b.remaining_chars() + b.add_field("Key", "Value") + assert b.remaining_chars() == initial - len("Key") - len("Value") + + +class TestMessageBuilder: + def test_build_single_embed_message(self): + mb = MessageBuilder(color=0xFF0000, timestamp="2025-01-01T00:00:00Z") + eb = mb.new_embed(title="Title", description="Desc") + eb.add_field("Key", "Value") + message = mb.build() + assert len(message.embeds) == 1 + assert message.embeds[0].title == "Title" + + def test_new_embed_creates_continuation(self): + mb = MessageBuilder(color=0xFF0000, timestamp="2025-01-01T00:00:00Z") + mb.new_embed(title="First", description="Desc") + mb.new_embed(title="Second", description="") + message = mb.build() + assert len(message.embeds) == 2 + + def test_set_content(self): + mb = MessageBuilder(color=0, timestamp="") + mb.set_content("hello world") + mb.new_embed(title="T", description="D") + message = mb.build() + assert message.content == "hello world" + + def test_current_embed_returns_active_builder(self): + mb = MessageBuilder(color=0, timestamp="") + eb = mb.new_embed(title="T", description="D") + assert mb.current_embed is eb + + +class TestValidateNotification: + def test_valid_notification_returns_empty(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + content=None, + embeds=[ + Embed( + title="Title", + description="Desc", + color=0, + fields=[Field(name="k", value="v")], + timestamp="", + ) + ], + ) + ], + ) + assert validate_notification(notification) == [] + + def test_title_too_long(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed( + title="x" * 257, + description="", + color=0, + fields=[], + timestamp="", + ) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("title" in v.lower() for v in violations) + + def test_field_value_too_long(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed( + title="T", + description="", + color=0, + fields=[Field(name="k", value="x" * 1025)], + timestamp="", + ) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("field value" in v.lower() for v in violations) + + def test_too_many_fields(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed( + title="T", + description="", + color=0, + fields=[Field(name="k", value="v") for _ in range(26)], + timestamp="", + ) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("field count" in v.lower() for v in violations) + + def test_embed_total_chars_too_large(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed( + title="x" * 256, + description="x" * 4096, + color=0, + fields=[Field(name="k", value="x" * 1024) for _ in range(3)], + timestamp="", + ) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("embed char" in v.lower() for v in violations) + + def test_content_too_long(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + content="x" * 2001, + embeds=[], + ) + ], + ) + violations = validate_notification(notification) + assert any("content" in v.lower() for v in violations) + + def test_too_many_embeds(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed(title="T", description="", color=0, fields=[], timestamp="") + for _ in range(11) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("embed count" in v.lower() for v in violations) From e9bf8cad382845068aa8d1da6193ad1474b5e2a4 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 8 Apr 2026 12:43:08 -0600 Subject: [PATCH 32/43] Fix double-counting bug in EmbedBuilder.can_add_field --- notifications/discord_limits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notifications/discord_limits.py b/notifications/discord_limits.py index a9098d0..34da11e 100644 --- a/notifications/discord_limits.py +++ b/notifications/discord_limits.py @@ -88,7 +88,7 @@ def can_add_field(self, name: str, value: str) -> bool: if len(self._fields) >= FIELDS_PER_EMBED: return False field_size = len(name or "") + len(value or "") - return self._fields_size + field_size <= self.remaining_chars() + return field_size <= self.remaining_chars() def add_field(self, name: str, value: str, inline: bool = False) -> bool: if not self.can_add_field(name, value): From 5d065ad38a6f1a2bdac887e32c41e91b88d158de Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 8 Apr 2026 12:45:13 -0600 Subject: [PATCH 33/43] Tasks 5 & 6: strip send_notification.py and formatting_utils.py to thin wrappers Remove all constants and chunking functions from send_notification.py (now delegates to discord_limits) and remove chunk_field_lines, emoji_for, RESOURCE_EMOJI, and MAX_FIELD_CHARS from formatting_utils.py. Replace test_embed_chunking.py to test the canonical discord_limits exports instead of the deleted private helpers. Co-Authored-By: Claude Sonnet 4.6 --- notifications/formatting/formatting_utils.py | 52 ----- notifications/send_notification.py | 116 +----------- tests/test_embed_chunking.py | 189 +++++-------------- 3 files changed, 47 insertions(+), 310 deletions(-) diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index 32ba2ed..51dde0f 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -1,58 +1,6 @@ from markdowndata import load from pathlib import Path -# ── Resource type badge emoji mapping ──────────────────────────────────────── -# Keys are lowercase for case-insensitive matching. -RESOURCE_EMOJI: dict[str, str] = { - "page": "📄", - "quiz": "📝", - "assignment": "📎", - "module": "📦", - "module_item": "📂", - "discussion": "💬", - "file": "📁", - "externalurl": "🔗", - "external_url": "🔗", - "announcement": "📢", - "syllabus": "📋", -} - - -def emoji_for(resource_type: str) -> str: - return RESOURCE_EMOJI.get(resource_type.lower(), "📌") - - -# ── Field chunking (Discord field value limit: 1024 chars) ────────────────── -MAX_FIELD_CHARS = 1024 - - -def chunk_field_lines( - lines: list[str], - max_chars: int = MAX_FIELD_CHARS, -) -> list[str]: - """Split a list of formatted lines into chunks that fit Discord's field value limit. - - Returns a list of joined strings, each under *max_chars*. - """ - chunks: list[str] = [] - current_lines: list[str] = [] - current_len = 0 - - for line in lines: - needed = len(line) + (1 if current_lines else 0) # +1 for newline - if current_lines and current_len + needed > max_chars: - chunks.append("\n".join(current_lines)) - current_lines = [line] - current_len = len(line) - else: - current_lines.append(line) - current_len += needed - - if current_lines: - chunks.append("\n".join(current_lines)) - - return chunks - STYLE_PATH = Path(__file__).parent / "style.md" diff --git a/notifications/send_notification.py b/notifications/send_notification.py index b503d83..f492c76 100644 --- a/notifications/send_notification.py +++ b/notifications/send_notification.py @@ -1,111 +1,8 @@ from discord_webhook import DiscordWebhook, DiscordEmbed +from .discord_limits import CONTENT_LIMIT, split_content from .resources import Embed, Notification -MAX_CONTENT_CHARS = 2000 -MAX_EMBED_CHARS = 5900 # 100-char safety margin below Discord's 6000 - - -def _calc_embed_size(embed: Embed) -> int: - 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: - 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]: - if _calc_embed_size(embed) <= max_chars: - return [embed] - - continuation_title = f"{embed.title} (continued)" - chunks = [] - current_fields = [] - - 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 "") - - 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 _split_content(content: str, max_chars: int = MAX_CONTENT_CHARS) -> 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 _build_discord_embed(embed_data: Embed) -> DiscordEmbed: embed = DiscordEmbed( @@ -153,22 +50,15 @@ def _execute_webhook(webhook_url: str, notification: Notification, content: str def send_notification(webhook_url: str, notification: Notification): for message in notification.messages: - # Split plain text content if it exceeds Discord's limit - content_chunks = _split_content(message.content, MAX_CONTENT_CHARS) if message.content else [None] - - # Chunk embeds if they exceed size limit - embed_chunks = [] - for embed_data in message.embeds: - embed_chunks.extend(_chunk_embed(embed_data)) + content_chunks = split_content(message.content, CONTENT_LIMIT) if message.content else [None] - # First chunk gets both content and embeds; subsequent chunks get content only for index, content in enumerate(content_chunks): try: response = _execute_webhook( webhook_url=webhook_url, notification=notification, content=content, - embeds=embed_chunks if index == 0 else [], + embeds=message.embeds if index == 0 else [], ) except Exception as e: print(f"Error sending notification: {e}") diff --git a/tests/test_embed_chunking.py b/tests/test_embed_chunking.py index 85e7806..8bf18c9 100644 --- a/tests/test_embed_chunking.py +++ b/tests/test_embed_chunking.py @@ -1,5 +1,10 @@ +from notifications.discord_limits import ( + EMBED_CHAR_LIMIT, + FIELDS_PER_EMBED, + EmbedBuilder, + calc_embed_size, +) from notifications.resources import Author, Embed, Field, Footer -from notifications.send_notification import MAX_EMBED_CHARS, _calc_embed_size, _chunk_embed class TestCalcEmbedSize: @@ -11,7 +16,7 @@ def test_basic_size(self): 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") + assert calc_embed_size(embed) == len("Hello") + len("World") + len("key") + len("val") def test_includes_footer_and_author(self): embed = Embed( @@ -23,7 +28,7 @@ def test_includes_footer_and_author(self): author=Author(name="AuthorName"), footer=Footer(text="FooterText"), ) - assert _calc_embed_size(embed) == len("T") + len("D") + len("AuthorName") + len("FooterText") + assert calc_embed_size(embed) == len("T") + len("D") + len("AuthorName") + len("FooterText") def test_empty_embed(self): embed = Embed( @@ -33,7 +38,7 @@ def test_empty_embed(self): fields=[], timestamp="", ) - assert _calc_embed_size(embed) == 0 + assert calc_embed_size(embed) == 0 def test_spacer_fields(self): embed = Embed( @@ -43,159 +48,53 @@ def test_spacer_fields(self): fields=[Field(name="\u200b", value="\u200b")], timestamp="", ) - assert _calc_embed_size(embed) == 2 + 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): - 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( +class TestEmbedBuilderLimits: + def test_builder_never_exceeds_embed_char_limit(self): + b = EmbedBuilder( title="Title", - description="Desc", + description="Description text", 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): - fields = [ - Field(name="Package", value="my-package"), - Field(name="Version", value="1.2.3"), - Field(name="Status", value="Published"), - ] - embed = Embed( + added = 0 + for i in range(100): + if not b.add_field(f"field-{i}", "x" * 500): + break + added += 1 + embed = b.build() + assert calc_embed_size(embed) <= EMBED_CHAR_LIMIT + assert added > 0 + + def test_builder_respects_field_count_limit(self): + b = EmbedBuilder(title="T", description="D", color=0, timestamp="") + for i in range(30): + b.add_field(f"f{i}", "v") + embed = b.build() + assert len(embed.fields) <= FIELDS_PER_EMBED + + def test_all_fields_preserved_when_within_limits(self): + b = EmbedBuilder(title="T", description="D", color=0, timestamp="") + for i in range(10): + b.add_field(f"f{i}", f"v{i}") + embed = b.build() + assert len(embed.fields) == 10 + + def test_typical_pypi_embed_stays_small(self): + b = EmbedBuilder( 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 + b.add_field("Package", "my-package") + b.add_field("Version", "1.2.3") + b.add_field("Status", "Published") + embed = b.build() + assert calc_embed_size(embed) < EMBED_CHAR_LIMIT From cf7829a15a4aff9464d2bcae7f221b7d69329ecd Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 8 Apr 2026 12:49:40 -0600 Subject: [PATCH 34/43] Rewrite canvas_format with tabulate overview and limit-aware builders Replace the old chunk_field_lines/emoji approach with the new 3-case notification format: error (red), needs-review (yellow), success (green). Uses tabulate pipe-format overview tables, EmbedBuilder/MessageBuilder for limit-aware field packing, and removes all emojis per spec. Co-Authored-By: Claude Opus 4.6 --- notifications/formatting/canvas_format.py | 214 ++++++++++++++-------- 1 file changed, 138 insertions(+), 76 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index 32fb006..bb5c143 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -1,30 +1,24 @@ +from __future__ import annotations + +from collections import Counter from datetime import datetime, timezone -from notifications.formatting.formatting_utils import ( - chunk_field_lines, - emoji_for, - get_course_style, - truncate_error, -) +from tabulate import tabulate + +from notifications.discord_limits import FIELD_VALUE_LIMIT, EmbedBuilder, MessageBuilder +from notifications.formatting.formatting_utils import get_course_style, truncate_error from notifications.formatting.plain_text_utils import ( dedupe_remaining_content, status_color, ) -from notifications.resources import ( - Author, - Embed, - Field, - Footer, - Notification, - WebhookMessage, -) +from notifications.resources import Author, Footer, Notification def has_content(data) -> bool: return bool( - data['deployed_content'] - or data['content_to_review'] - or data['error'] + data["deployed_content"] + or data["content_to_review"] + or data["error"] ) @@ -32,11 +26,78 @@ def requires_review(data) -> bool: return bool(data["content_to_review"] or data["error"]) +def _build_overview_table(deployed_content: list, content_to_review: list) -> str: + """Build a tabulate overview table counting distinct items by resource type.""" + seen: set[str] = set() + type_counts: Counter = Counter() + + for content_type, name, *_ in deployed_content: + if name not in seen: + seen.add(name) + type_counts[content_type] += 1 + + for name, *_ in content_to_review: + if name not in seen: + seen.add(name) + type_counts["assignment"] += 1 + + rows = sorted(type_counts.items(), key=lambda r: (-r[1], r[0])) + table = tabulate(rows, headers=["Resource Type", "Count"], tablefmt="pipe") + return f"```\n{table}\n```" + + def _format_item(resource_type: str, name: str, link: str | None) -> str: label = f"`{resource_type}`" if link: - return f"📌 {label} [{name}]({link})" - return f"📌 {label} {name}" + return f"{label} [{name}]({link})" + return f"{label} {name}" + + +def _add_items_to_builder( + builder: EmbedBuilder, + message_builder: MessageBuilder, + header: str, + lines: list[str], + author: Author | None, + footer: Footer | None, + continuation_title: str, +): + """Pack item lines into fields, starting new embeds as needed.""" + current_lines: list[str] = [] + is_first_field = True + + def flush_field(): + nonlocal current_lines, is_first_field, builder + if not current_lines: + return + value = "\n".join(current_lines) + name = header if is_first_field else "\u200b" + + if not builder.can_add_field(name, value): + builder = message_builder.new_embed( + title=continuation_title, + description="", + footer=footer, + ) + + builder.add_field(name, value, inline=False) + is_first_field = False + current_lines = [] + + for line in lines: + test_value = "\n".join(current_lines + [line]) + test_name = header if is_first_field else "\u200b" + + would_exceed_field = len(test_value) > FIELD_VALUE_LIMIT + would_exceed_embed = not builder.can_add_field(test_name, test_value) + + if current_lines and (would_exceed_field or would_exceed_embed): + flush_field() + + current_lines.append(line) + + flush_field() + return builder def format_notification( @@ -48,94 +109,95 @@ def format_notification( author_icon, branch, action_url, + cicd_role_id=None, ) -> Notification: style = get_course_style("canvas") timestamp = datetime.now(timezone.utc).isoformat() + footer = Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]) + author_obj = Author(name=author, icon_url=author_icon) - # ── Title ──────────────────────────────────────────────────────────── + # -- Title ---------------------------------------------------------------- if data["error"]: - title = f"{course_name} — Deploy failed" + title = f"CS {course_id} | {course_name} -- Deploy failed" elif data["content_to_review"]: - title = f"{course_name} — Deploy complete — items need review" + title = f"CS {course_id} | {course_name} -- Deploy complete -- items need review" else: - title = f"{course_name} — Deploy complete" + title = f"CS {course_id} | {course_name} -- Deploy complete" + + continuation_title = f"{title} (continued)" - # ── Color ──────────────────────────────────────────────────────────── + # -- Color ---------------------------------------------------------------- color = status_color( has_error=bool(data["error"]), needs_review=requires_review(data), ) - # ── Description ────────────────────────────────────────────────────── + # -- Content message ------------------------------------------------------ + content = None + if data["error"] and cicd_role_id: + content = f"<@&{cicd_role_id}> ERROR -- MDXCanvas failed to deploy. View [here]({action_url})" + elif data["content_to_review"] and cicd_role_id: + content = f"<@&{cicd_role_id}> -- Deployed Resources to Review" + + # -- Description ---------------------------------------------------------- description = f"**Branch:** `{branch}`" if data["error"]: truncated = truncate_error(data["error"]) - description += f"\n\n{truncated}" - - # ── Fields ─────────────────────────────────────────────────────────── - SPACER = [ - Field(name="\u200b", value="\u200b", inline=False), - Field(name="\u200b", value="\u200b", inline=False), - ] - fields = [] + description += f"\n\n**Error:**\n{truncated}" + + # -- Build message -------------------------------------------------------- + mb = MessageBuilder(color=color, timestamp=timestamp) + if content: + mb.set_content(content) + + eb = mb.new_embed( + title=title, + description=description, + author=author_obj, + footer=footer, + ) + # Error case: no fields, just the error in description + if data["error"]: + return Notification( + username=style["username"], + avatar_url=style["avatar_url"], + messages=[mb.build()], + ) + + # -- Overview table ------------------------------------------------------- + if data["deployed_content"] or data["content_to_review"]: + table = _build_overview_table(data["deployed_content"], data["content_to_review"]) + eb.add_field("Over View:", table, inline=False) + + # -- Needs review items --------------------------------------------------- if data["content_to_review"]: lines = [ - f"> {_format_item('Assignment', name, link)}" + _format_item("assignment", name, link) for name, link in data["content_to_review"] ] - chunks = chunk_field_lines(lines) - header = f"⚠️ Needs review ({len(data['content_to_review'])})" - if fields: - fields.extend(SPACER) - for i, chunk in enumerate(chunks): - fields.append(Field( - name=header if i == 0 else "\u200b", - value=chunk, - inline=False, - )) + header = f"Needs review ({len(data['content_to_review'])})" + eb = _add_items_to_builder( + eb, mb, header, lines, author_obj, footer, continuation_title, + ) + # -- Remaining resources -------------------------------------------------- remaining = dedupe_remaining_content( data["deployed_content"], data["content_to_review"] ) if remaining: lines = [ - f"> {_format_item(content_type, name, url)}" + _format_item(content_type, name, url) for content_type, name, url in remaining ] - chunks = chunk_field_lines(lines) - header = f"✅ Deployed ({len(remaining)})" - if fields: - fields.extend(SPACER) - for i, chunk in enumerate(chunks): - fields.append(Field( - name=header if i == 0 else "\u200b", - value=chunk, - inline=False, - )) - - # ── Build notification ─────────────────────────────────────────────── + header = f"Remaining Resources ({len(remaining)})" + eb = _add_items_to_builder( + eb, mb, header, lines, author_obj, footer, continuation_title, + ) + return Notification( username=style["username"], avatar_url=style["avatar_url"], - messages=[ - WebhookMessage( - content=None, - embeds=[ - Embed( - title=f"CS {course_id} | {title}", - description=description, - color=color, - fields=fields, - timestamp=timestamp, - author=Author(name=author, icon_url=author_icon), - footer=Footer( - text=style["footer_text"], - icon_url=style["footer_icon_url"], - ), - ) - ], - ) - ], + messages=[mb.build()], ) From 8d352ab0786c7c086f7428cd61d29a90018b3cf3 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 8 Apr 2026 12:52:52 -0600 Subject: [PATCH 35/43] Rewrite docker_format.py with MessageBuilder, simplify send_course.py to pass cicd_role_id, and update formatter tests Co-Authored-By: Claude Sonnet 4.6 --- notifications/formatting/docker_format.py | 97 ++---- notifications/send_course.py | 10 +- tests/test_course_text_formatters.py | 346 ++++++++++++---------- 3 files changed, 228 insertions(+), 225 deletions(-) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index 757bcf8..7d1b154 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -1,22 +1,16 @@ from datetime import datetime, timezone -from notifications.formatting.formatting_utils import chunk_field_lines, get_course_style, truncate_error +from notifications.discord_limits import EmbedBuilder, MessageBuilder +from notifications.formatting.formatting_utils import get_course_style, truncate_error from notifications.formatting.plain_text_utils import status_color -from notifications.resources import ( - Author, - Embed, - Field, - Footer, - Notification, - WebhookMessage, -) +from notifications.resources import Author, Footer, Notification def has_content(data) -> bool: return bool( - data['updated_images'] - or data['failed_images'] - or data['error'] + data["updated_images"] + or data["failed_images"] + or data["error"] ) @@ -33,85 +27,54 @@ def format_notification( author_icon, branch, action_url, + cicd_role_id=None, ) -> Notification: style = get_course_style("docker") timestamp = datetime.now(timezone.utc).isoformat() + footer = Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]) + author_obj = Author(name=author, icon_url=author_icon) - # ── Title ──────────────────────────────────────────────────────────── + # -- Title ---------------------------------------------------------------- if data["error"]: - title = f"{course_name} — Build failed" + title = f"CS {course_id} | {course_name} -- Build failed" elif data["failed_images"]: - title = f"{course_name} — Build complete — failures" + title = f"CS {course_id} | {course_name} -- Build complete -- failures" else: - title = f"{course_name} — Build complete" + title = f"CS {course_id} | {course_name} -- Build complete" - # ── Color ──────────────────────────────────────────────────────────── + # -- Color ---------------------------------------------------------------- color = status_color( has_error=bool(data["error"]), needs_review=requires_review(data), ) - # ── Description ────────────────────────────────────────────────────── + # -- Description ---------------------------------------------------------- description = f"**Branch:** `{branch}`" - if data["error"]: truncated = truncate_error(data["error"]) description += f"\n\n{truncated}" - # ── Fields ─────────────────────────────────────────────────────────── - SPACER = [ - Field(name="\u200b", value="\u200b", inline=False), - Field(name="\u200b", value="\u200b", inline=False), - ] - fields = [] + # -- Build message -------------------------------------------------------- + mb = MessageBuilder(color=color, timestamp=timestamp) + eb = mb.new_embed( + title=title, + description=description, + author=author_obj, + footer=footer, + ) if data["failed_images"]: - lines = [f"> ❌ `{image}`" for image in data["failed_images"]] - chunks = chunk_field_lines(lines) - header = f"❌ Failed ({len(data['failed_images'])})" - if fields: - fields.extend(SPACER) - for i, chunk in enumerate(chunks): - fields.append(Field( - name=header if i == 0 else "\u200b", - value=chunk, - inline=False, - )) + lines = [f"`{image}`" for image in data["failed_images"]] + value = "\n".join(lines) + eb.add_field(f"Failed ({len(data['failed_images'])})", value, inline=False) if data["updated_images"]: - lines = [f"> 📦 `{image}`" for image in data["updated_images"]] - chunks = chunk_field_lines(lines) - header = f"✅ Built ({len(data['updated_images'])})" - if fields: - fields.extend(SPACER) - for i, chunk in enumerate(chunks): - fields.append(Field( - name=header if i == 0 else "\u200b", - value=chunk, - inline=False, - )) + lines = [f"`{image}`" for image in data["updated_images"]] + value = "\n".join(lines) + eb.add_field(f"Built ({len(data['updated_images'])})", value, inline=False) - # ── Build notification ─────────────────────────────────────────────── return Notification( username=style["username"], avatar_url=style["avatar_url"], - messages=[ - WebhookMessage( - content=None, - embeds=[ - Embed( - title=f"CS {course_id} | {title}", - description=description, - color=color, - fields=fields, - timestamp=timestamp, - author=Author(name=author, icon_url=author_icon), - footer=Footer( - text=style["footer_text"], - icon_url=style["footer_icon_url"], - ), - ) - ], - ) - ], + messages=[mb.build()], ) diff --git a/notifications/send_course.py b/notifications/send_course.py index b405cad..ba0c0ce 100644 --- a/notifications/send_course.py +++ b/notifications/send_course.py @@ -7,8 +7,8 @@ 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), + "canvas": (canvas_format.format_notification, canvas_format.has_content), + "docker": (docker_format.format_notification, docker_format.has_content), } @@ -20,7 +20,7 @@ def main(ntype, payload, course_id, course_name, course_url, author, author_icon if ntype not in FORMATTERS: raise ValueError("Invalid notification type. Use 'canvas' or 'docker'.") - format_notification, has_content, requires_review = FORMATTERS[ntype] + format_notification, has_content = FORMATTERS[ntype] with open(payload, 'r') as file: data = json.load(file) @@ -38,11 +38,9 @@ def main(ntype, payload, course_id, course_name, course_url, author, author_icon author_icon=author_icon or "", branch=branch_name, action_url=action_url, + cicd_role_id=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) diff --git a/tests/test_course_text_formatters.py b/tests/test_course_text_formatters.py index 40e6e5a..a258483 100644 --- a/tests/test_course_text_formatters.py +++ b/tests/test_course_text_formatters.py @@ -1,154 +1,196 @@ 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_with_review_items(): - 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="https://github.com/robbykapua.png", - branch="main", - action_url="https://github.com/testkapua/testing-repo/actions/runs/123", - ) - - message = notification.messages[0] - embed = message.embeds[0] - - # Content is None (reserved for role mentions only) - assert message.content is None - - # Embed metadata - assert "Review" in embed.title or "review" in embed.title - assert embed.author is not None - assert embed.author.name == "robbykapua" - assert embed.footer is not None - assert embed.timestamp - - # Description has branch info - assert "`main`" in embed.description - - # Fields: review items + deployed items - assert len(embed.fields) == 2 - - review_field = embed.fields[0] - assert "Needs review" in review_field.name - assert "(2)" in review_field.name - assert "Needs Review" in review_field.value - assert "Professor Approval" in review_field.value - - deployed_field = embed.fields[1] - assert "Deployed" in deployed_field.name - # Review items deduped from deployed - assert "https://courses.example/review-me" not in deployed_field.value - assert "Week 12 Overview" in deployed_field.value - assert "Lab 8 Instructions" in deployed_field.value - - -def test_canvas_notification_with_error(): - 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/testing-repo/actions/runs/123", - ) - - message = notification.messages[0] - embed = message.embeds[0] - - assert "failed" in embed.title - assert "RuntimeError: boom" in embed.description - assert message.content is None - - -def test_docker_notification_with_failures(): - 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/testing-repo/actions/runs/123", - ) - - message = notification.messages[0] - embed = message.embeds[0] - - assert message.content is None - assert "failures" in embed.title.lower() - - # Failed field first, then built - assert len(embed.fields) == 2 - - failed_field = embed.fields[0] - assert "Failed" in failed_field.name - assert "`project-base`" in failed_field.value - - built_field = embed.fields[1] - assert "Built" in built_field.name - assert "`lab-1`" in built_field.value - assert "`lab-2`" in built_field.value - - -def test_docker_notification_with_error(): - 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/testing-repo/actions/runs/123", - ) - - message = notification.messages[0] - embed = message.embeds[0] - - assert "failed" in embed.title.lower() - assert "ValueError: bad image" in embed.description - assert message.content is None +from notifications.discord_limits import validate_notification + + +class TestCanvasNotificationSuccess: + def test_success_has_overview_table(self): + 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"), + ], + "content_to_review": [], + "error": "", + }, + course_id="235", + course_name="CS 235 Spring 2026", + course_url="https://courses.example/cs235", + author="robbykapua", + author_icon="https://github.com/robbykapua.png", + branch="main", + action_url="https://github.com/actions/runs/123", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert message.content is None + assert "Deploy complete" in embed.title + assert "`main`" in embed.description + assert embed.author is not None + assert embed.footer is not None + + # First field is overview table + overview = embed.fields[0] + assert "Over View" in overview.name + assert "page" in overview.value + assert "assignment" in overview.value + + # Remaining resources field follows + remaining = embed.fields[1] + assert "Remaining Resources" in remaining.name + assert "Week 12 Overview" in remaining.value + + assert validate_notification(notification) == [] + + +class TestCanvasNotificationReview: + def test_review_has_content_ping_and_review_section(self): + notification = format_canvas_notification( + data={ + "deployed_content": [ + ("page", "Week 12 Overview", "https://courses.example/week-12"), + ("assignment", "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="https://github.com/robbykapua.png", + branch="main", + action_url="https://github.com/actions/runs/123", + cicd_role_id="123456", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert "<@&123456>" in message.content + assert "review" in embed.title.lower() + + # Has overview, needs review, and remaining fields + field_names = [f.name for f in embed.fields] + assert any("Over View" in n for n in field_names) + assert any("Needs review" in n for n in field_names) + assert any("Remaining Resources" in n for n in field_names) + + # Review items present + review_field = next(f for f in embed.fields if "Needs review" in f.name) + assert "Needs Review" in review_field.value + assert "Professor Approval" in review_field.value + + # Deduplication: review item not in remaining + remaining_field = next(f for f in embed.fields if "Remaining Resources" in f.name) + assert "https://courses.example/review-me" not in remaining_field.value + assert "Week 12 Overview" in remaining_field.value + + assert validate_notification(notification) == [] + + +class TestCanvasNotificationError: + def test_error_has_content_ping_and_error_in_description(self): + 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/actions/runs/123", + cicd_role_id="123456", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert "failed" in embed.title.lower() + assert "RuntimeError: boom" in embed.description + assert "<@&123456>" in message.content + assert "ERROR" in message.content + assert embed.fields == [] + + assert validate_notification(notification) == [] + + +class TestDockerNotificationFailures: + def test_docker_with_failures(self): + 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/actions/runs/123", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert message.content is None + assert "failures" in embed.title.lower() + assert len(embed.fields) == 2 + + failed_field = embed.fields[0] + assert "Failed" in failed_field.name + assert "`project-base`" in failed_field.value + + built_field = embed.fields[1] + assert "Built" in built_field.name + assert "`lab-1`" in built_field.value + assert "`lab-2`" in built_field.value + + assert validate_notification(notification) == [] + + +class TestDockerNotificationError: + def test_docker_with_error(self): + 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/actions/runs/123", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert "failed" in embed.title.lower() + assert "ValueError: bad image" in embed.description + assert message.content is None + + assert validate_notification(notification) == [] From 2aa6ca9e142d6e724e2496d8b2d13df06099fa46 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 8 Apr 2026 12:54:21 -0600 Subject: [PATCH 36/43] Add integration tests with real payload and edge cases Co-Authored-By: Claude Sonnet 4.6 --- tests/test_discord_limits.py | 116 +++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/test_discord_limits.py b/tests/test_discord_limits.py index eb714a6..bd302dc 100644 --- a/tests/test_discord_limits.py +++ b/tests/test_discord_limits.py @@ -300,3 +300,119 @@ def test_too_many_embeds(self): ) violations = validate_notification(notification) assert any("embed count" in v.lower() for v in violations) + + +import json +from pathlib import Path + +from notifications.formatting.canvas_format import format_notification + + +class TestIntegrationWithRealPayload: + @staticmethod + def _load_payload(): + path = Path(__file__).parent / "test-mdxcanvas-payload.json" + with open(path) as f: + return json.load(f) + + def test_success_case_passes_all_limits(self): + data = self._load_payload() + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="https://github.com/robbykap.png", + branch="main", + action_url="https://github.com/actions/runs/1", + ) + violations = validate_notification(notification) + assert violations == [], f"Limit violations: {violations}" + + def test_review_case_passes_all_limits(self): + data = self._load_payload() + data["content_to_review"] = [ + ("lab-notebook-week1", "https://byu.instructure.com/courses/20736/assignments/1332409"), + ("group-presentation", "https://byu.instructure.com/courses/20736/assignments/1332413"), + ] + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="https://github.com/robbykap.png", + branch="main", + action_url="https://github.com/actions/runs/1", + cicd_role_id="999888777", + ) + violations = validate_notification(notification) + assert violations == [], f"Limit violations: {violations}" + + def test_error_case_passes_all_limits(self): + data = { + "deployed_content": [], + "content_to_review": [], + "error": "SONDecodeError: Expecting value: line 1 column 1 (char 0)", + } + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="", + branch="main", + action_url="https://github.com/actions/runs/1", + cicd_role_id="999888777", + ) + violations = validate_notification(notification) + assert violations == [], f"Limit violations: {violations}" + + def test_overview_table_has_correct_type_count(self): + data = self._load_payload() + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="", + branch="main", + action_url="https://github.com/actions/runs/1", + ) + overview_field = notification.messages[0].embeds[0].fields[0] + for resource_type in ["module", "assignment", "quiz", "page"]: + assert resource_type in overview_field.value + + def test_massive_payload_passes_all_limits(self): + data = { + "deployed_content": [ + ("page", f"page-{i}", f"https://example.com/pages/{i}") + for i in range(500) + ], + "content_to_review": [], + "error": "", + } + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="", + branch="main", + action_url="https://github.com/actions/runs/1", + ) + violations = validate_notification(notification) + assert violations == [], f"Limit violations: {violations}" + + def test_empty_payload_has_no_content(self): + from notifications.formatting.canvas_format import has_content + data = { + "deployed_content": [], + "content_to_review": [], + "error": "", + } + assert has_content(data) is False From 7c6d6dc1d1c667d6ef6104a39717fcd7fcf27e8b Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 8 Apr 2026 13:07:04 -0600 Subject: [PATCH 37/43] 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 6260acb..99b21b3 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 markdowndata ${{ inputs.extra-packages }} + run: pip install discord-webhook markdowndata tabulate ${{ inputs.extra-packages }} - name: Create logs directory run: mkdir -p "$LOGS_DIR" From 54bdbf82814eecd74ad37556573db815b7cbe23d Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 8 Apr 2026 13:13:59 -0600 Subject: [PATCH 38/43] Update canvas_format.py --- notifications/formatting/canvas_format.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index bb5c143..1e723fd 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -42,15 +42,15 @@ def _build_overview_table(deployed_content: list, content_to_review: list) -> st type_counts["assignment"] += 1 rows = sorted(type_counts.items(), key=lambda r: (-r[1], r[0])) - table = tabulate(rows, headers=["Resource Type", "Count"], tablefmt="pipe") + table = tabulate(rows, headers=["Resource Type", "Count"], tablefmt="presto") return f"```\n{table}\n```" def _format_item(resource_type: str, name: str, link: str | None) -> str: label = f"`{resource_type}`" if link: - return f"{label} [{name}]({link})" - return f"{label} {name}" + return f"{label:<3} [{name}]({link})" + return f"{label:<3} {name}" def _add_items_to_builder( From 12d2dbfbeccde6adecc0e1ebd384b3a0975f9f94 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 8 Apr 2026 13:48:31 -0600 Subject: [PATCH 39/43] Update canvas_format.py --- notifications/formatting/canvas_format.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index 1e723fd..66c3ff0 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections import Counter +from collections import Counter, defaultdict from datetime import datetime, timezone from tabulate import tabulate @@ -46,11 +46,12 @@ def _build_overview_table(deployed_content: list, content_to_review: list) -> st return f"```\n{table}\n```" -def _format_item(resource_type: str, name: str, link: str | None) -> str: +def _format_item(resource_type: str, name: str, max_len: int, link: str | None) -> str: label = f"`{resource_type}`" if link: - return f"{label:<3} [{name}]({link})" - return f"{label:<3} {name}" + link = f"[{name}]({link})" + return f"{label:{max_len}} {link:<3}" + return f"{label:{max_len}} {name:<3}" def _add_items_to_builder( @@ -166,6 +167,13 @@ def format_notification( messages=[mb.build()], ) + # -- Calculate max resource type length for formatting --------------------------------------------- + grouped = defaultdict(int) + for rtype, _, _ in data["deployed_content"]: + grouped[rtype] += 1 + + max_len = max(len(rtype) for rtype in grouped) + # -- Overview table ------------------------------------------------------- if data["deployed_content"] or data["content_to_review"]: table = _build_overview_table(data["deployed_content"], data["content_to_review"]) @@ -174,8 +182,8 @@ def format_notification( # -- Needs review items --------------------------------------------------- if data["content_to_review"]: lines = [ - _format_item("assignment", name, link) - for name, link in data["content_to_review"] + _format_item(content_type, name, max_len, url) + for content_type, name, url in data["content_to_review"] ] header = f"Needs review ({len(data['content_to_review'])})" eb = _add_items_to_builder( @@ -188,7 +196,7 @@ def format_notification( ) if remaining: lines = [ - _format_item(content_type, name, url) + _format_item(content_type, name, max_len, url) for content_type, name, url in remaining ] header = f"Remaining Resources ({len(remaining)})" From 39bc7451835b9e9ab09ab0193c93f9df4c0cedf9 Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 13 Apr 2026 18:02:25 -0600 Subject: [PATCH 40/43] Finalized discord notifications for docker and mdxcanvas --- .DS_Store | Bin 0 -> 6148 bytes ...2026-04-08-canvas-notification-redesign.md | 1751 +++++ ...-08-canvas-notification-redesign-design.md | 159 + notifications/discord_limits.py | 44 +- notifications/formatting/canvas_format.py | 22 +- notifications/formatting/docker_format.py | 74 +- notifications/formatting/plain_text_utils.py | 6 +- tests/send_test_notification.py | 40 + tests/test-docker-payload.json | 507 ++ tests/test-error-docker-payload.json | 5 + tests/test-error-mdxcanvas-payload.json | 17 + tests/test-large-mdxcanvas-payload.json | 6151 +++++++++++++++++ tests/test-mdxcanvas-payload.json | 826 --- tests/test-urgent-mdxcanvas-payload.json | 17 + tests/test_course_text_formatters.py | 4 +- tests/test_discord_limits.py | 46 +- 16 files changed, 8802 insertions(+), 867 deletions(-) create mode 100644 .DS_Store create mode 100644 docs/superpowers/plans/2026-04-08-canvas-notification-redesign.md create mode 100644 docs/superpowers/specs/2026-04-08-canvas-notification-redesign-design.md create mode 100644 tests/send_test_notification.py create mode 100644 tests/test-docker-payload.json create mode 100644 tests/test-error-docker-payload.json create mode 100644 tests/test-error-mdxcanvas-payload.json create mode 100644 tests/test-large-mdxcanvas-payload.json delete mode 100644 tests/test-mdxcanvas-payload.json create mode 100644 tests/test-urgent-mdxcanvas-payload.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e7e6bce18581279fca36a4fc2388855157e75e75 GIT binary patch literal 6148 zcmeHKOHUI~6h5bdbVOi;if_}{jSE!3g&G%*1H=%c5Q= zy7LG419aoYg}=b18%_KR`~yArQRzds#^{{n-tWwL+!Y@Xbq_jmiQLf=Z2t}wt70O-Fi6LPiVc>6LK)kzy^tN(vVGCN{Uk74(j%EYy zmI7X)IB`hWG%kOaLK+=t>rg) zJs&%9EwY_(MLaEX9+aB8Q(E%9deO{GaTYs1^Fo>6mpuwuedx0?uiL!F%8|^?D~DmE zjiPyCcXxa&d;QwpSax^f+9c@{S0}IT?HTFgxhpppid(@p<1eUq5~QP6%7DfuydH354dK^jL*L9}x}~&nr2pu^;E;K2cqB7?e0X#;Gdgne)X15$=ZfY?$#J(Tp(u8W z2RyWcnpZT3B5HZo^+I|{xfn7cO)!$<@E#8;?pX{WDX2+(L15%v6V{b zm*+M%A}=nq=*y3sCyPHYgVW}sn7OKOMN3{75sG5l zatdpKQ}Tj2X7f1a+sjTEI;`T^PYXUZB$|-B=c|tEvr6vPz9QAgSr?UNLaxcPk!flg zG52xHlDe@d`pH@I%9FG1>X0;Cf+=_a>kz?ncnxphEqs7a@CEiUg(jZH9A3fecmr*m z#(Q`li@1qCGOVi=Qq5oeyk1cQHb`mt)pznC3plHgwV9zKyFmw}`@3CEdsKz^q0~V?!Z%NKmB`s#MgU7^u?8p4D-d#)d+b z4k&k~b@a}tzfe%_PWG$}2ecF>bHYHvKsy76)T1fx|C!%^|F **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign Canvas deploy notifications with tabulate overview tables, limit-aware formatting via builder classes, and no emojis. + +**Architecture:** Extract Discord limit constants and builders into `notifications/discord_limits.py`. Formatters build notifications incrementally using `EmbedBuilder`/`MessageBuilder` to respect character budgets. `send_notification.py` becomes a thin webhook sender. + +**Tech Stack:** Python, tabulate (already installed), discord-webhook, pytest + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `notifications/discord_limits.py` | Create | Constants, `EmbedBuilder`, `MessageBuilder`, `validate_notification`, `calc_embed_size`, `split_content` | +| `notifications/send_notification.py` | Modify | Thin webhook sender only — imports limits from `discord_limits` | +| `notifications/formatting/canvas_format.py` | Rewrite | Three-case notification formatter with tabulate overview table | +| `notifications/formatting/formatting_utils.py` | Modify | Remove `chunk_field_lines` and `emoji_for`, keep `get_course_style`, `truncate_error` | +| `notifications/formatting/docker_format.py` | Modify | Use `EmbedBuilder` instead of `chunk_field_lines` | +| `notifications/send_course.py` | Modify | Pass `cicd_role_id` to formatter, remove post-hoc content injection | +| `tests/test_discord_limits.py` | Create | Builder unit tests + limit validation tests | +| `tests/test_embed_chunking.py` | Modify | Update imports, replace `_chunk_embed` tests with builder tests | +| `tests/test_course_text_formatters.py` | Modify | Update canvas tests for new format/signature, fix docker tests | + +--- + +### Task 1: Create `discord_limits.py` — constants and moved functions + +**Files:** +- Create: `notifications/discord_limits.py` +- Test: `tests/test_discord_limits.py` + +- [ ] **Step 1: Write failing tests for constants and `calc_embed_size`** + +Create `tests/test_discord_limits.py`: + +```python +from notifications.discord_limits import ( + TITLE_LIMIT, + DESCRIPTION_LIMIT, + FIELD_NAME_LIMIT, + FIELD_VALUE_LIMIT, + FIELDS_PER_EMBED, + FOOTER_LIMIT, + AUTHOR_NAME_LIMIT, + EMBED_CHAR_LIMIT, + MAX_EMBEDS_PER_MESSAGE, + CONTENT_LIMIT, + calc_embed_size, + split_content, +) +from notifications.resources import Author, Embed, Field, Footer + + +class TestConstants: + def test_embed_char_limit(self): + assert EMBED_CHAR_LIMIT == 6000 + + def test_field_value_limit(self): + assert FIELD_VALUE_LIMIT == 1024 + + def test_fields_per_embed(self): + assert FIELDS_PER_EMBED == 25 + + def test_content_limit(self): + assert CONTENT_LIMIT == 2000 + + def test_max_embeds_per_message(self): + assert MAX_EMBEDS_PER_MESSAGE == 10 + + +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 + + +class TestSplitContent: + def test_short_content_unchanged(self): + result = split_content("hello", 2000) + assert result == ["hello"] + + def test_long_content_splits_on_paragraphs(self): + paragraphs = ["paragraph " + str(i) for i in range(200)] + content = "\n\n".join(paragraphs) + result = split_content(content, 100) + assert len(result) > 1 + for chunk in result: + assert len(chunk) <= 100 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_discord_limits.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'notifications.discord_limits'` + +- [ ] **Step 3: Create `discord_limits.py` with constants and functions** + +Create `notifications/discord_limits.py`: + +```python +from __future__ import annotations + +from .resources import Embed + +# ── Discord API limits ────────────────────────────────────────────────────── +TITLE_LIMIT = 256 +DESCRIPTION_LIMIT = 4096 +FIELD_NAME_LIMIT = 256 +FIELD_VALUE_LIMIT = 1024 +FIELDS_PER_EMBED = 25 +FOOTER_LIMIT = 2048 +AUTHOR_NAME_LIMIT = 256 +EMBED_CHAR_LIMIT = 6000 +MAX_EMBEDS_PER_MESSAGE = 10 +CONTENT_LIMIT = 2000 + + +def calc_embed_size(embed: Embed) -> int: + 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 split_content(content: str, max_chars: int = CONTENT_LIMIT) -> list[str]: + if len(content) <= max_chars: + return [content] + + paragraphs = content.split("\n\n") + chunks: list[str] = [] + 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 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_discord_limits.py -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add notifications/discord_limits.py tests/test_discord_limits.py +git commit -m "Add discord_limits module with constants, calc_embed_size, split_content" +``` + +--- + +### Task 2: Add `EmbedBuilder` to `discord_limits.py` + +**Files:** +- Modify: `notifications/discord_limits.py` +- Modify: `tests/test_discord_limits.py` + +- [ ] **Step 1: Write failing tests for `EmbedBuilder`** + +Append to `tests/test_discord_limits.py`: + +```python +from notifications.discord_limits import EmbedBuilder + + +class TestEmbedBuilder: + def test_build_produces_embed(self): + b = EmbedBuilder( + title="Title", + description="Desc", + color=0xFF0000, + timestamp="2025-01-01T00:00:00Z", + author=Author(name="Bot"), + footer=Footer(text="Footer"), + ) + embed = b.build() + assert embed.title == "Title" + assert embed.description == "Desc" + assert embed.color == 0xFF0000 + assert embed.author.name == "Bot" + assert embed.footer.text == "Footer" + assert embed.fields == [] + + def test_add_field_success(self): + b = EmbedBuilder( + title="T", description="D", color=0, timestamp="", + ) + result = b.add_field("Name", "Value", inline=False) + assert result is True + embed = b.build() + assert len(embed.fields) == 1 + assert embed.fields[0].name == "Name" + assert embed.fields[0].value == "Value" + + def test_can_add_field_checks_char_limit(self): + b = EmbedBuilder( + title="x" * 5000, description="", color=0, timestamp="", + ) + # Only ~1000 chars left, try to add 1500-char field + assert b.can_add_field("name", "x" * 1500) is False + + def test_can_add_field_checks_field_count(self): + b = EmbedBuilder( + title="T", description="D", color=0, timestamp="", + ) + for i in range(25): + b.add_field(f"f{i}", "v") + assert b.can_add_field("f25", "v") is False + + def test_add_field_returns_false_when_full(self): + b = EmbedBuilder( + title="x" * 5900, description="", color=0, timestamp="", + ) + result = b.add_field("name", "x" * 500) + assert result is False + assert len(b.build().fields) == 0 + + def test_remaining_chars_decreases(self): + b = EmbedBuilder( + title="Hello", description="World", color=0, timestamp="", + ) + initial = b.remaining_chars() + b.add_field("Key", "Value") + assert b.remaining_chars() == initial - len("Key") - len("Value") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_discord_limits.py::TestEmbedBuilder -v` +Expected: FAIL — `ImportError: cannot import name 'EmbedBuilder'` + +- [ ] **Step 3: Implement `EmbedBuilder`** + +Add to `notifications/discord_limits.py`: + +```python +from .resources import Author, Embed, Field, Footer + + +class EmbedBuilder: + def __init__( + self, + title: str, + description: str, + color: int, + timestamp: str, + author: Author | None = None, + footer: Footer | None = None, + ): + self._title = title + self._description = description + self._color = color + self._timestamp = timestamp + self._author = author + self._footer = footer + self._fields: list[Field] = [] + + self._base_size = len(title or "") + len(description or "") + if author: + self._base_size += len(author.name or "") + if footer: + self._base_size += len(footer.text or "") + self._fields_size = 0 + + def remaining_chars(self) -> int: + return EMBED_CHAR_LIMIT - self._base_size - self._fields_size + + def can_add_field(self, name: str, value: str) -> bool: + if len(self._fields) >= FIELDS_PER_EMBED: + return False + field_size = len(name or "") + len(value or "") + return self._fields_size + field_size <= self.remaining_chars() + + def add_field(self, name: str, value: str, inline: bool = False) -> bool: + if not self.can_add_field(name, value): + return False + self._fields.append(Field(name=name, value=value, inline=inline)) + self._fields_size += len(name or "") + len(value or "") + return True + + def build(self) -> Embed: + return Embed( + title=self._title, + description=self._description, + color=self._color, + fields=list(self._fields), + timestamp=self._timestamp, + author=self._author, + footer=self._footer, + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_discord_limits.py::TestEmbedBuilder -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add notifications/discord_limits.py tests/test_discord_limits.py +git commit -m "Add EmbedBuilder with char budget and field count tracking" +``` + +--- + +### Task 3: Add `MessageBuilder` to `discord_limits.py` + +**Files:** +- Modify: `notifications/discord_limits.py` +- Modify: `tests/test_discord_limits.py` + +- [ ] **Step 1: Write failing tests for `MessageBuilder`** + +Append to `tests/test_discord_limits.py`: + +```python +from notifications.discord_limits import MessageBuilder + + +class TestMessageBuilder: + def test_build_single_embed_message(self): + mb = MessageBuilder(color=0xFF0000, timestamp="2025-01-01T00:00:00Z") + eb = mb.new_embed(title="Title", description="Desc") + eb.add_field("Key", "Value") + message = mb.build() + assert len(message.embeds) == 1 + assert message.embeds[0].title == "Title" + + def test_new_embed_creates_continuation(self): + mb = MessageBuilder(color=0xFF0000, timestamp="2025-01-01T00:00:00Z") + mb.new_embed(title="First", description="Desc") + mb.new_embed(title="Second", description="") + message = mb.build() + assert len(message.embeds) == 2 + + def test_set_content(self): + mb = MessageBuilder(color=0, timestamp="") + mb.set_content("hello world") + mb.new_embed(title="T", description="D") + message = mb.build() + assert message.content == "hello world" + + def test_current_embed_returns_active_builder(self): + mb = MessageBuilder(color=0, timestamp="") + eb = mb.new_embed(title="T", description="D") + assert mb.current_embed is eb +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_discord_limits.py::TestMessageBuilder -v` +Expected: FAIL — `ImportError: cannot import name 'MessageBuilder'` + +- [ ] **Step 3: Implement `MessageBuilder`** + +Add to `notifications/discord_limits.py`: + +```python +from .resources import WebhookMessage + + +class MessageBuilder: + def __init__(self, color: int, timestamp: str): + self._color = color + self._timestamp = timestamp + self._content: str | None = None + self._embeds: list[EmbedBuilder] = [] + + def set_content(self, content: str): + self._content = content + + @property + def current_embed(self) -> EmbedBuilder | None: + return self._embeds[-1] if self._embeds else None + + def new_embed( + self, + title: str, + description: str, + author: Author | None = None, + footer: Footer | None = None, + ) -> EmbedBuilder: + eb = EmbedBuilder( + title=title, + description=description, + color=self._color, + timestamp=self._timestamp, + author=author, + footer=footer, + ) + self._embeds.append(eb) + return eb + + def build(self) -> WebhookMessage: + return WebhookMessage( + content=self._content, + embeds=[eb.build() for eb in self._embeds], + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_discord_limits.py::TestMessageBuilder -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add notifications/discord_limits.py tests/test_discord_limits.py +git commit -m "Add MessageBuilder for multi-embed message construction" +``` + +--- + +### Task 4: Add `validate_notification` to `discord_limits.py` + +**Files:** +- Modify: `notifications/discord_limits.py` +- Modify: `tests/test_discord_limits.py` + +- [ ] **Step 1: Write failing tests for `validate_notification`** + +Append to `tests/test_discord_limits.py`: + +```python +from notifications.discord_limits import validate_notification +from notifications.resources import Notification, WebhookMessage + + +class TestValidateNotification: + def test_valid_notification_returns_empty(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + content=None, + embeds=[ + Embed( + title="Title", + description="Desc", + color=0, + fields=[Field(name="k", value="v")], + timestamp="", + ) + ], + ) + ], + ) + assert validate_notification(notification) == [] + + def test_title_too_long(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed( + title="x" * 257, + description="", + color=0, + fields=[], + timestamp="", + ) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("title" in v.lower() for v in violations) + + def test_field_value_too_long(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed( + title="T", + description="", + color=0, + fields=[Field(name="k", value="x" * 1025)], + timestamp="", + ) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("field value" in v.lower() for v in violations) + + def test_too_many_fields(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed( + title="T", + description="", + color=0, + fields=[Field(name="k", value="v") for _ in range(26)], + timestamp="", + ) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("field count" in v.lower() for v in violations) + + def test_embed_total_chars_too_large(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed( + title="x" * 256, + description="x" * 4096, + color=0, + fields=[Field(name="k", value="x" * 1024) for _ in range(3)], + timestamp="", + ) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("embed char" in v.lower() for v in violations) + + def test_content_too_long(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + content="x" * 2001, + embeds=[], + ) + ], + ) + violations = validate_notification(notification) + assert any("content" in v.lower() for v in violations) + + def test_too_many_embeds(self): + notification = Notification( + username="Bot", + messages=[ + WebhookMessage( + embeds=[ + Embed(title="T", description="", color=0, fields=[], timestamp="") + for _ in range(11) + ], + ) + ], + ) + violations = validate_notification(notification) + assert any("embed count" in v.lower() for v in violations) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_discord_limits.py::TestValidateNotification -v` +Expected: FAIL — `ImportError: cannot import name 'validate_notification'` + +- [ ] **Step 3: Implement `validate_notification`** + +Add to `notifications/discord_limits.py`: + +```python +from .resources import Notification + + +def validate_notification(notification: Notification) -> list[str]: + violations: list[str] = [] + + for msg_idx, message in enumerate(notification.messages): + prefix = f"Message {msg_idx}" + + if message.content and len(message.content) > CONTENT_LIMIT: + violations.append( + f"{prefix}: Content length {len(message.content)} exceeds {CONTENT_LIMIT}" + ) + + if len(message.embeds) > MAX_EMBEDS_PER_MESSAGE: + violations.append( + f"{prefix}: Embed count {len(message.embeds)} exceeds {MAX_EMBEDS_PER_MESSAGE}" + ) + + for emb_idx, embed in enumerate(message.embeds): + ep = f"{prefix}, Embed {emb_idx}" + + if embed.title and len(embed.title) > TITLE_LIMIT: + violations.append( + f"{ep}: Title length {len(embed.title)} exceeds {TITLE_LIMIT}" + ) + + if embed.description and len(embed.description) > DESCRIPTION_LIMIT: + violations.append( + f"{ep}: Description length {len(embed.description)} exceeds {DESCRIPTION_LIMIT}" + ) + + if embed.footer and len(embed.footer.text or "") > FOOTER_LIMIT: + violations.append( + f"{ep}: Footer length {len(embed.footer.text)} exceeds {FOOTER_LIMIT}" + ) + + if embed.author and len(embed.author.name or "") > AUTHOR_NAME_LIMIT: + violations.append( + f"{ep}: Author name length {len(embed.author.name)} exceeds {AUTHOR_NAME_LIMIT}" + ) + + if len(embed.fields) > FIELDS_PER_EMBED: + violations.append( + f"{ep}: Field count {len(embed.fields)} exceeds {FIELDS_PER_EMBED}" + ) + + for fld_idx, field in enumerate(embed.fields): + if field.name and len(field.name) > FIELD_NAME_LIMIT: + violations.append( + f"{ep}, Field {fld_idx}: Field name length {len(field.name)} exceeds {FIELD_NAME_LIMIT}" + ) + if field.value and len(field.value) > FIELD_VALUE_LIMIT: + violations.append( + f"{ep}, Field {fld_idx}: Field value length {len(field.value)} exceeds {FIELD_VALUE_LIMIT}" + ) + + total = calc_embed_size(embed) + if total > EMBED_CHAR_LIMIT: + violations.append( + f"{ep}: Embed char total {total} exceeds {EMBED_CHAR_LIMIT}" + ) + + return violations +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_discord_limits.py::TestValidateNotification -v` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add notifications/discord_limits.py tests/test_discord_limits.py +git commit -m "Add validate_notification for post-build limit checking" +``` + +--- + +### Task 5: Refactor `send_notification.py` and update `test_embed_chunking.py` + +**Files:** +- Modify: `notifications/send_notification.py` +- Modify: `tests/test_embed_chunking.py` + +- [ ] **Step 1: Rewrite `send_notification.py` as thin sender** + +Replace the contents of `notifications/send_notification.py` with: + +```python +from discord_webhook import DiscordWebhook, DiscordEmbed + +from .discord_limits import CONTENT_LIMIT, split_content +from .resources import Embed, Notification + + +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 or None, + ) + + 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: + embed.add_embed_field( + name=field.name or "\u200b", + value=field.value or "\u200b", + inline=field.inline, + ) + + return 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): + for message in notification.messages: + content_chunks = split_content(message.content, CONTENT_LIMIT) if message.content else [None] + + for index, content in enumerate(content_chunks): + try: + response = _execute_webhook( + webhook_url=webhook_url, + notification=notification, + content=content, + embeds=message.embeds if index == 0 else [], + ) + except Exception as e: + print(f"Error sending notification: {e}") + continue + + if response.status_code >= 400: + print(f"Discord returned status {response.status_code}: {response.text}") + else: + print("Sent message successfully.") +``` + +- [ ] **Step 2: Rewrite `test_embed_chunking.py` to test builders instead** + +Replace `tests/test_embed_chunking.py` with: + +```python +from notifications.discord_limits import ( + EMBED_CHAR_LIMIT, + FIELDS_PER_EMBED, + EmbedBuilder, + calc_embed_size, +) +from notifications.resources import Author, Embed, Field, Footer + + +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="", + ) + assert calc_embed_size(embed) == 2 + + +class TestEmbedBuilderLimits: + def test_builder_never_exceeds_embed_char_limit(self): + b = EmbedBuilder( + title="Title", + description="Description text", + color=0, + timestamp="", + author=Author(name="Bot"), + footer=Footer(text="Footer"), + ) + added = 0 + for i in range(100): + if not b.add_field(f"field-{i}", "x" * 500): + break + added += 1 + embed = b.build() + assert calc_embed_size(embed) <= EMBED_CHAR_LIMIT + assert added > 0 + + def test_builder_respects_field_count_limit(self): + b = EmbedBuilder(title="T", description="D", color=0, timestamp="") + for i in range(30): + b.add_field(f"f{i}", "v") + embed = b.build() + assert len(embed.fields) <= FIELDS_PER_EMBED + + def test_all_fields_preserved_when_within_limits(self): + b = EmbedBuilder(title="T", description="D", color=0, timestamp="") + for i in range(10): + b.add_field(f"f{i}", f"v{i}") + embed = b.build() + assert len(embed.fields) == 10 + + def test_typical_pypi_embed_stays_small(self): + b = EmbedBuilder( + title="PyPI Update", + description="A new version has been published.", + color=0x3B82F6, + timestamp="2025-01-01T00:00:00Z", + author=Author(name="PyPI Bot"), + footer=Footer(text="BeanLab Dev Utils"), + ) + b.add_field("Package", "my-package") + b.add_field("Version", "1.2.3") + b.add_field("Status", "Published") + embed = b.build() + assert calc_embed_size(embed) < EMBED_CHAR_LIMIT +``` + +- [ ] **Step 3: Run all tests** + +Run: `python -m pytest tests/test_embed_chunking.py tests/test_discord_limits.py -v` +Expected: All PASS + +- [ ] **Step 4: Commit** + +```bash +git add notifications/send_notification.py tests/test_embed_chunking.py +git commit -m "Refactor send_notification to thin sender, update chunking tests for builders" +``` + +--- + +### Task 6: Clean up `formatting_utils.py` + +**Files:** +- Modify: `notifications/formatting/formatting_utils.py` + +- [ ] **Step 1: Remove `chunk_field_lines`, `emoji_for`, `RESOURCE_EMOJI`, and `MAX_FIELD_CHARS`** + +The file should become: + +```python +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 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```" +``` + +- [ ] **Step 2: Run existing tests to verify nothing breaks** + +Run: `python -m pytest tests/ -v --ignore=tests/test_course_text_formatters.py` +Expected: PASS (formatter tests will be updated later since they test the old format) + +- [ ] **Step 3: Commit** + +```bash +git add notifications/formatting/formatting_utils.py +git commit -m "Remove chunk_field_lines and emoji_for from formatting_utils" +``` + +--- + +### Task 7: Rewrite `canvas_format.py` + +**Files:** +- Rewrite: `notifications/formatting/canvas_format.py` + +- [ ] **Step 1: Write the new canvas formatter** + +Replace `notifications/formatting/canvas_format.py` with: + +```python +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone + +from tabulate import tabulate + +from notifications.discord_limits import EmbedBuilder, MessageBuilder +from notifications.formatting.formatting_utils import get_course_style, truncate_error +from notifications.formatting.plain_text_utils import ( + dedupe_remaining_content, + status_color, +) +from notifications.resources import Author, Footer, Notification + + +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 _build_overview_table(deployed_content: list, content_to_review: list) -> str: + """Build a tabulate overview table counting distinct items by resource type.""" + seen: set[str] = set() + type_counts: Counter = Counter() + + for content_type, name, *_ in deployed_content: + if name not in seen: + seen.add(name) + type_counts[content_type] += 1 + + for name, *_ in content_to_review: + if name not in seen: + seen.add(name) + type_counts["assignment"] += 1 + + rows = sorted(type_counts.items(), key=lambda r: (-r[1], r[0])) + table = tabulate(rows, headers=["Resource Type", "Count"], tablefmt="pipe") + return f"```\n{table}\n```" + + +def _format_item(resource_type: str, name: str, link: str | None) -> str: + label = f"`{resource_type}`" + if link: + return f"{label} [{name}]({link})" + return f"{label} {name}" + + +def _add_items_to_builder( + builder: EmbedBuilder, + message_builder: MessageBuilder, + header: str, + lines: list[str], + author: Author | None, + footer: Footer | None, + continuation_title: str, +): + """Pack item lines into fields, starting new embeds as needed.""" + current_lines: list[str] = [] + current_len = 0 + is_first_field = True + + def flush_field(): + nonlocal current_lines, current_len, is_first_field, builder + if not current_lines: + return + value = "\n".join(current_lines) + name = header if is_first_field else "\u200b" + + if not builder.can_add_field(name, value): + builder = message_builder.new_embed( + title=continuation_title, + description="", + footer=footer, + ) + + builder.add_field(name, value, inline=False) + is_first_field = False + current_lines = [] + current_len = 0 + + for line in lines: + needed = len(line) + (1 if current_lines else 0) + test_value = "\n".join(current_lines + [line]) + test_name = header if is_first_field else "\u200b" + + if current_lines and not builder.can_add_field(test_name, test_value): + flush_field() + + current_lines.append(line) + current_len += needed + + flush_field() + return builder + + +def format_notification( + data, + course_id, + course_name, + course_url, + author, + author_icon, + branch, + action_url, + cicd_role_id=None, +) -> Notification: + style = get_course_style("canvas") + timestamp = datetime.now(timezone.utc).isoformat() + footer = Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]) + author_obj = Author(name=author, icon_url=author_icon) + + # ── Title ──────────────────────────────────────────────────────────── + if data["error"]: + title = f"CS {course_id} | {course_name} -- Deploy failed" + elif data["content_to_review"]: + title = f"CS {course_id} | {course_name} -- Deploy complete -- items need review" + else: + title = f"CS {course_id} | {course_name} -- Deploy complete" + + continuation_title = f"{title} (continued)" + + # ── Color ──────────────────────────────────────────────────────────── + color = status_color( + has_error=bool(data["error"]), + needs_review=requires_review(data), + ) + + # ── Content message ────────────────────────────────────────────────── + content = None + if data["error"] and cicd_role_id: + content = f"<@&{cicd_role_id}> ERROR -- MDXCanvas failed to deploy. View [here]({action_url})" + elif data["content_to_review"] and cicd_role_id: + content = f"<@&{cicd_role_id}> -- Deployed Resources to Review" + + # ── Description ────────────────────────────────────────────────────── + description = f"**Branch:** `{branch}`" + + if data["error"]: + truncated = truncate_error(data["error"]) + description += f"\n\n**Error:**\n{truncated}" + + # ── Build message ──────────────────────────────────────────────────── + mb = MessageBuilder(color=color, timestamp=timestamp) + if content: + mb.set_content(content) + + eb = mb.new_embed( + title=title, + description=description, + author=author_obj, + footer=footer, + ) + + # Error case: no fields, just the error in description + if data["error"]: + return Notification( + username=style["username"], + avatar_url=style["avatar_url"], + messages=[mb.build()], + ) + + # ── Overview table ─────────────────────────────────────────────────── + if data["deployed_content"] or data["content_to_review"]: + table = _build_overview_table(data["deployed_content"], data["content_to_review"]) + eb.add_field("Over View:", table, inline=False) + + # ── Needs review items ─────────────────────────────────────────────── + if data["content_to_review"]: + lines = [ + _format_item("assignment", name, link) + for name, link in data["content_to_review"] + ] + header = f"Needs review ({len(data['content_to_review'])})" + eb = _add_items_to_builder( + eb, mb, header, lines, author_obj, footer, continuation_title, + ) + + # ── Remaining resources ────────────────────────────────────────────── + remaining = dedupe_remaining_content( + data["deployed_content"], data["content_to_review"] + ) + if remaining: + lines = [ + _format_item(content_type, name, url) + for content_type, name, url in remaining + ] + header = f"Remaining Resources ({len(remaining)})" + eb = _add_items_to_builder( + eb, mb, header, lines, author_obj, footer, continuation_title, + ) + + return Notification( + username=style["username"], + avatar_url=style["avatar_url"], + messages=[mb.build()], + ) +``` + +- [ ] **Step 2: Smoke-test the formatter with the test payload** + +Run: `python -c " +import json +from notifications.formatting.canvas_format import format_notification +from notifications.discord_limits import validate_notification + +with open('tests/test-mdxcanvas-payload.json') as f: + data = json.load(f) + +n = format_notification( + data=data, course_id='110', course_name='CS 110 Course Updates', + course_url='https://byu.instructure.com/courses/20736', + author='robbykap', author_icon='', branch='main', + action_url='https://github.com/actions/runs/1', +) +violations = validate_notification(n) +print(f'Messages: {len(n.messages)}') +for msg in n.messages: + print(f' Embeds: {len(msg.embeds)}') + for emb in msg.embeds: + print(f' Fields: {len(emb.fields)}, Title: {emb.title[:60]}') +print(f'Violations: {violations}') +" +` +Expected: No violations, at least 1 message with fields + +- [ ] **Step 3: Commit** + +```bash +git add notifications/formatting/canvas_format.py +git commit -m "Rewrite canvas_format with tabulate overview and limit-aware builders" +``` + +--- + +### Task 8: Update `send_course.py` and `docker_format.py` + +**Files:** +- Modify: `notifications/send_course.py` +- Modify: `notifications/formatting/docker_format.py` + +- [ ] **Step 1: Update `send_course.py` to pass `cicd_role_id` to formatter** + +In `notifications/send_course.py`, change the `main()` function. The `format_notification` call should pass `cicd_role_id`, and remove the post-hoc content injection: + +Replace lines 32-44: + +```python + notification = format_notification( + data=data, + course_id=course_id, + course_name=course_name, + course_url=course_url, + author=author, + author_icon=author_icon or "", + branch=branch_name, + action_url=action_url, + cicd_role_id=cicd_role_id, + ) + + send_notification(webhook_url, notification) +``` + +(Remove the `if requires_review(data) and cicd_role_id...` block entirely.) + +- [ ] **Step 2: Update `docker_format.py` to use `EmbedBuilder` and accept `cicd_role_id`** + +Replace `notifications/formatting/docker_format.py` with: + +```python +from datetime import datetime, timezone + +from notifications.discord_limits import EmbedBuilder, MessageBuilder +from notifications.formatting.formatting_utils import get_course_style, truncate_error +from notifications.formatting.plain_text_utils import status_color +from notifications.resources import Author, Footer, Notification + + +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, + course_name, + course_url, + author, + author_icon, + branch, + action_url, + cicd_role_id=None, +) -> Notification: + style = get_course_style("docker") + timestamp = datetime.now(timezone.utc).isoformat() + footer = Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]) + author_obj = Author(name=author, icon_url=author_icon) + + # ── Title ──────────────────────────────────────────────────────────── + if data["error"]: + title = f"CS {course_id} | {course_name} -- Build failed" + elif data["failed_images"]: + title = f"CS {course_id} | {course_name} -- Build complete -- failures" + else: + title = f"CS {course_id} | {course_name} -- Build complete" + + # ── Color ──────────────────────────────────────────────────────────── + color = status_color( + has_error=bool(data["error"]), + needs_review=requires_review(data), + ) + + # ── Description ────────────────────────────────────────────────────── + description = f"**Branch:** `{branch}`" + if data["error"]: + truncated = truncate_error(data["error"]) + description += f"\n\n{truncated}" + + # ── Build message ──────────────────────────────────────────────────── + mb = MessageBuilder(color=color, timestamp=timestamp) + eb = mb.new_embed( + title=title, + description=description, + author=author_obj, + footer=footer, + ) + + if data["failed_images"]: + lines = [f"`{image}`" for image in data["failed_images"]] + value = "\n".join(lines) + eb.add_field(f"Failed ({len(data['failed_images'])})", value, inline=False) + + if data["updated_images"]: + lines = [f"`{image}`" for image in data["updated_images"]] + value = "\n".join(lines) + eb.add_field(f"Built ({len(data['updated_images'])})", value, inline=False) + + return Notification( + username=style["username"], + avatar_url=style["avatar_url"], + messages=[mb.build()], + ) +``` + +- [ ] **Step 3: Run all tests except formatter tests (which need updating next)** + +Run: `python -m pytest tests/test_embed_chunking.py tests/test_discord_limits.py tests/test_workflow_wiring.py -v` +Expected: All PASS + +- [ ] **Step 4: Commit** + +```bash +git add notifications/send_course.py notifications/formatting/docker_format.py +git commit -m "Update send_course and docker_format for new builder pattern and cicd_role_id" +``` + +--- + +### Task 9: Update formatter tests + +**Files:** +- Modify: `tests/test_course_text_formatters.py` + +- [ ] **Step 1: Rewrite canvas formatter tests for new format** + +Replace `tests/test_course_text_formatters.py` with: + +```python +from notifications.formatting.canvas_format import format_notification as format_canvas_notification +from notifications.formatting.docker_format import format_notification as format_docker_notification +from notifications.discord_limits import validate_notification + + +class TestCanvasNotificationSuccess: + def test_success_has_overview_table(self): + 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"), + ], + "content_to_review": [], + "error": "", + }, + course_id="235", + course_name="CS 235 Spring 2026", + course_url="https://courses.example/cs235", + author="robbykapua", + author_icon="https://github.com/robbykapua.png", + branch="main", + action_url="https://github.com/actions/runs/123", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert message.content is None + assert "Deploy complete" in embed.title + assert "`main`" in embed.description + assert embed.author is not None + assert embed.footer is not None + + # First field is overview table + overview = embed.fields[0] + assert "Over View" in overview.name + assert "page" in overview.value + assert "assignment" in overview.value + + # Remaining resources field follows + remaining = embed.fields[1] + assert "Remaining Resources" in remaining.name + assert "Week 12 Overview" in remaining.value + + assert validate_notification(notification) == [] + + +class TestCanvasNotificationReview: + def test_review_has_content_ping_and_review_section(self): + notification = format_canvas_notification( + data={ + "deployed_content": [ + ("page", "Week 12 Overview", "https://courses.example/week-12"), + ("assignment", "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="https://github.com/robbykapua.png", + branch="main", + action_url="https://github.com/actions/runs/123", + cicd_role_id="123456", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert "<@&123456>" in message.content + assert "review" in embed.title.lower() + + # Has overview, needs review, and remaining fields + field_names = [f.name for f in embed.fields] + assert any("Over View" in n for n in field_names) + assert any("Needs review" in n for n in field_names) + assert any("Remaining Resources" in n for n in field_names) + + # Review items present + review_field = next(f for f in embed.fields if "Needs review" in f.name) + assert "Needs Review" in review_field.value + assert "Professor Approval" in review_field.value + + # Deduplication: review item not in remaining + remaining_field = next(f for f in embed.fields if "Remaining Resources" in f.name) + assert "https://courses.example/review-me" not in remaining_field.value + assert "Week 12 Overview" in remaining_field.value + + assert validate_notification(notification) == [] + + +class TestCanvasNotificationError: + def test_error_has_content_ping_and_error_in_description(self): + 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/actions/runs/123", + cicd_role_id="123456", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert "failed" in embed.title.lower() + assert "RuntimeError: boom" in embed.description + assert "<@&123456>" in message.content + assert "ERROR" in message.content + assert embed.fields == [] + + assert validate_notification(notification) == [] + + +class TestDockerNotificationFailures: + def test_docker_with_failures(self): + 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/actions/runs/123", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert message.content is None + assert "failures" in embed.title.lower() + assert len(embed.fields) == 2 + + failed_field = embed.fields[0] + assert "Failed" in failed_field.name + assert "`project-base`" in failed_field.value + + built_field = embed.fields[1] + assert "Built" in built_field.name + assert "`lab-1`" in built_field.value + assert "`lab-2`" in built_field.value + + assert validate_notification(notification) == [] + + +class TestDockerNotificationError: + def test_docker_with_error(self): + 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/actions/runs/123", + ) + message = notification.messages[0] + embed = message.embeds[0] + + assert "failed" in embed.title.lower() + assert "ValueError: bad image" in embed.description + assert message.content is None + + assert validate_notification(notification) == [] +``` + +- [ ] **Step 2: Run formatter tests** + +Run: `python -m pytest tests/test_course_text_formatters.py -v` +Expected: All PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_course_text_formatters.py +git commit -m "Update formatter tests for new canvas/docker format with limit validation" +``` + +--- + +### Task 10: Integration tests with real payload + +**Files:** +- Modify: `tests/test_discord_limits.py` + +- [ ] **Step 1: Add integration tests using the test payload** + +Append to `tests/test_discord_limits.py`: + +```python +import json +from pathlib import Path + +from notifications.formatting.canvas_format import format_notification +from notifications.discord_limits import validate_notification + + +class TestIntegrationWithRealPayload: + @staticmethod + def _load_payload(): + path = Path(__file__).parent / "test-mdxcanvas-payload.json" + with open(path) as f: + return json.load(f) + + def test_success_case_passes_all_limits(self): + data = self._load_payload() + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="https://github.com/robbykap.png", + branch="main", + action_url="https://github.com/actions/runs/1", + ) + violations = validate_notification(notification) + assert violations == [], f"Limit violations: {violations}" + + def test_review_case_passes_all_limits(self): + data = self._load_payload() + data["content_to_review"] = [ + ("lab-notebook-week1", "https://byu.instructure.com/courses/20736/assignments/1332409"), + ("group-presentation", "https://byu.instructure.com/courses/20736/assignments/1332413"), + ] + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="https://github.com/robbykap.png", + branch="main", + action_url="https://github.com/actions/runs/1", + cicd_role_id="999888777", + ) + violations = validate_notification(notification) + assert violations == [], f"Limit violations: {violations}" + + def test_error_case_passes_all_limits(self): + data = { + "deployed_content": [], + "content_to_review": [], + "error": "SONDecodeError: Expecting value: line 1 column 1 (char 0)", + } + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="", + branch="main", + action_url="https://github.com/actions/runs/1", + cicd_role_id="999888777", + ) + violations = validate_notification(notification) + assert violations == [], f"Limit violations: {violations}" + + def test_overview_table_has_correct_type_count(self): + data = self._load_payload() + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="", + branch="main", + action_url="https://github.com/actions/runs/1", + ) + overview_field = notification.messages[0].embeds[0].fields[0] + # The test payload has these types: module, module_item, assignment, quiz, + # page, quiz_question, quiz_question_order, syllabus, announcement + for resource_type in ["module", "assignment", "quiz", "page"]: + assert resource_type in overview_field.value + + def test_massive_payload_passes_all_limits(self): + data = { + "deployed_content": [ + ("page", f"page-{i}", f"https://example.com/pages/{i}") + for i in range(500) + ], + "content_to_review": [], + "error": "", + } + notification = format_notification( + data=data, + course_id="110", + course_name="CS 110 Course Updates", + course_url="https://byu.instructure.com/courses/20736", + author="robbykap", + author_icon="", + branch="main", + action_url="https://github.com/actions/runs/1", + ) + violations = validate_notification(notification) + assert violations == [], f"Limit violations: {violations}" + + def test_empty_payload_has_no_content(self): + from notifications.formatting.canvas_format import has_content + data = { + "deployed_content": [], + "content_to_review": [], + "error": "", + } + assert has_content(data) is False +``` + +- [ ] **Step 2: Run integration tests** + +Run: `python -m pytest tests/test_discord_limits.py::TestIntegrationWithRealPayload -v` +Expected: All PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_discord_limits.py +git commit -m "Add integration tests with real payload and edge cases" +``` + +--- + +### Task 11: Full test suite verification + +- [ ] **Step 1: Run all tests** + +Run: `python -m pytest tests/ -v` +Expected: All tests PASS with no collection errors + +- [ ] **Step 2: Final commit if any fixups needed** + +```bash +git add -A +git commit -m "Fix any remaining test issues" +``` diff --git a/docs/superpowers/specs/2026-04-08-canvas-notification-redesign-design.md b/docs/superpowers/specs/2026-04-08-canvas-notification-redesign-design.md new file mode 100644 index 0000000..504be2d --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-canvas-notification-redesign-design.md @@ -0,0 +1,159 @@ +# Canvas Notification Redesign + +## Overview + +Redesign the Canvas deploy notification system to produce cleaner Discord messages with tabulate-generated overview tables, aligned resource listings, and limit-aware formatting that prevents Discord API errors. + +## Decisions + +- **Approach:** Limit-aware formatting (Approach B) — formatters build content incrementally, tracking character budget via builder classes +- **Resource listings:** Plain markdown with alignment, keeping links clickable—wrap resource types in backticks for fixed-width font +- **Item display:** Every item listed fully, including quiz_question and quiz_question_order +- **Overview table counts:** Deduplicated union across both `deployed_content` and `content_to_review` +- **Content message:** Built inside `canvas_format.py` (formatter owns full message shape), role ID passed in +- **Refactor:** Extract limits/chunking into `discord_limits.py`, keep `send_notification.py` as webhook sender name + +## Architecture + +### New module: `notifications/discord_limits.py` + +Constants: + +``` +TITLE_LIMIT = 256 +DESCRIPTION_LIMIT = 4096 +FIELD_NAME_LIMIT = 256 +FIELD_VALUE_LIMIT = 1024 +FIELDS_PER_EMBED = 25 +FOOTER_LIMIT = 2048 +AUTHOR_NAME_LIMIT = 256 +EMBED_CHAR_LIMIT = 6000 +MAX_EMBEDS_PER_MESSAGE = 10 +CONTENT_LIMIT = 2000 +``` + +Classes: + +- **`EmbedBuilder`** — stateful builder tracking character budget + - `__init__(title, description, color, timestamp, author, footer)` — sets base metadata, calculates initial char usage + - `remaining_chars() -> int` + - `can_add_field(name, value) -> bool` — checks char limit and 25-field limit + - `add_field(name, value, inline) -> bool` — adds if fits, returns False otherwise + - `build() -> Embed` + +- **`MessageBuilder`** — manages multiple embeds per message + - Tracks embed count against `MAX_EMBEDS_PER_MESSAGE` + - `new_embed(...)` — starts new embed when current fills up + - `build() -> WebhookMessage` + +Functions: + +- `calc_embed_size(embed: Embed) -> int` — moved from `send_notification.py` +- `split_content(content: str, max_chars: int) -> list[str]` — moved from `send_notification.py` +- `validate_notification(notification: Notification) -> list[str]` — returns limit violations + +### Refactored: `notifications/send_notification.py` + +Keeps only webhook-sending logic: +- `_build_discord_embed(embed_data)` — converts `Embed` dataclass to `DiscordEmbed` +- `_execute_webhook(webhook_url, notification, content, embeds)` — sends via `discord_webhook` +- `send_notification(webhook_url, notification)` — iterates messages, splits content if needed via `split_content()`, sends + +Removed: all constants, `_calc_embed_size`, `_chunk_embed`, `_build_chunk`, `_split_content`. + +### Rewritten: `notifications/formatting/canvas_format.py` + +Three notification cases: + +**Case 1: Error (red)** +- Content: `@CICD ERROR—MDXCanvas failed to deploy. View [here]({action_url})` +- Embed: title, branch in description, error in code block. No fields, no table. + +**Case 2: Needs Review (yellow)** +- Content: `<@&{role_id}>—Deployed Resources to Review` +- Embed fields: + 1. Overview table (tabulate `pipe` format in code block) — resource type x count + 2. Needs Review items — `assignment\` [name](link)` per line + 3. Remaining Resources — `type\` [name](link)` per line + +**Case 3: Success (green)** +- Content: None +- Embed fields: + 1. Overview table + 2. Remaining Resources + +Overview table built with `tabulate(rows, headers=["Resource Type", "Count"], tablefmt="pipe")` wrapped in a code block. Count is from deduplicated union of both lists. + +Resource lines packed into field values using `EmbedBuilder.can_add_field()`. When a field fills, a new field or embed is started. + +`format_notification()` signature adds `cicd_role_id` parameter so the formatter can build the content message. + +### Modified: `notifications/formatting/formatting_utils.py` + +- Remove `chunk_field_lines()` (replaced by `EmbedBuilder`) +- Keep `get_course_style()`, `truncate_error()` + +### Modified: `notifications/formatting/docker_format.py` + +- Replace `chunk_field_lines` usage with `EmbedBuilder` for consistency with canvas_format + +### Modified: `notifications/send_course.py` + +- Pass `cicd_role_id` into `format_notification()` instead of applying it after the fact +- Remove post-hoc content message injection + +### Dependency + +NO PROJECT DEPENDENCIES — this is not a pypi package or anything similar just an internal module + +## Test Strategy + +### New: `tests/test_discord_limits.py` + +**Limit validation tests** — verify every generated component respects Discord limits: +- Title ≤ 256, Description ≤ 4096, Field name ≤ 256, Field value ≤ 1024 +- Fields per embed ≤ 25, Embed total chars ≤ 6000 +- Embeds per message ≤ 10, Content ≤ 2000 + +**Integration tests** using `test-mdxcanvas-payload.json`: +- Load payload, run `format_notification()` for all 3 cases +- Run `validate_notification()` — assert zero violations +- Verify overview table row count matches distinct resource type count +- Verify all `content_to_review` items appear in needs-review fields +- Verify no item in both needs-review and remaining sections + +**EmbedBuilder unit tests:** +- Refuses field when char budget exhausted +- Refuses field when 25-field limit hit +- `remaining_chars()` decreases correctly +- `build()` produces valid `Embed` + +**Edge cases:** +- Empty payload — `has_content()` returns False +- Error-only payload +- Massive payload (500+ items) — still valid notifications + +### Modified: `tests/test_embed_chunking.py` + +- Update imports from `discord_limits` +- Replace `_chunk_embed` tests with `EmbedBuilder` tests + +## File Change Summary + +| File | Action | +|---|---| +| `notifications/discord_limits.py` | New | +| `notifications/send_notification.py` | Modify — remove constants/chunking | +| `notifications/formatting/canvas_format.py` | Rewrite | +| `notifications/formatting/formatting_utils.py` | Modify — remove `chunk_field_lines` | +| `notifications/formatting/docker_format.py` | Modify — update imports | +| `notifications/send_course.py` | Modify — pass role ID to formatter | +| `tests/test_discord_limits.py` | New | +| `tests/test_embed_chunking.py` | Modify — update imports/tests | + +## Not Changed + +- `notifications/resources.py` — dataclasses unchanged +- `notifications/formatting/plain_text_utils.py` — `status_color()` and `dedupe_remaining_content()` unchanged + +## DO NOT ADD EMOJIS — we want to keep the tone professional and clear, especially for error notifications. \ No newline at end of file diff --git a/notifications/discord_limits.py b/notifications/discord_limits.py index 34da11e..8755bf5 100644 --- a/notifications/discord_limits.py +++ b/notifications/discord_limits.py @@ -143,11 +143,38 @@ def new_embed( self._embeds.append(eb) return eb - def build(self) -> WebhookMessage: - return WebhookMessage( - content=self._content, - embeds=[eb.build() for eb in self._embeds], - ) + def build(self) -> list[WebhookMessage]: + """Build webhook messages, grouping embeds so each message's + combined embed character total stays within EMBED_CHAR_LIMIT + and each message has at most MAX_EMBEDS_PER_MESSAGE embeds.""" + built_embeds = [eb.build() for eb in self._embeds] + messages: list[WebhookMessage] = [] + current_embeds: list[Embed] = [] + current_size = 0 + + for embed in built_embeds: + embed_size = calc_embed_size(embed) + would_exceed_chars = current_embeds and current_size + embed_size > EMBED_CHAR_LIMIT + would_exceed_count = len(current_embeds) >= MAX_EMBEDS_PER_MESSAGE + + if current_embeds and (would_exceed_chars or would_exceed_count): + messages.append(WebhookMessage( + content=self._content if not messages else None, + embeds=current_embeds, + )) + current_embeds = [] + current_size = 0 + + current_embeds.append(embed) + current_size += embed_size + + if current_embeds: + messages.append(WebhookMessage( + content=self._content if not messages else None, + embeds=current_embeds, + )) + + return messages if messages else [WebhookMessage(content=self._content, embeds=[])] # -- validate_notification ---------------------------------------------------- @@ -212,4 +239,11 @@ def validate_notification(notification: Notification) -> list[str]: f"{ep}: Embed char total {total} exceeds {EMBED_CHAR_LIMIT}" ) + # Combined embed size across all embeds in a single message + combined = sum(calc_embed_size(e) for e in message.embeds) + if combined > EMBED_CHAR_LIMIT: + violations.append( + f"{prefix}: Combined embed char total {combined} exceeds {EMBED_CHAR_LIMIT}" + ) + return violations diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index 66c3ff0..1f67742 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -36,10 +36,10 @@ def _build_overview_table(deployed_content: list, content_to_review: list) -> st seen.add(name) type_counts[content_type] += 1 - for name, *_ in content_to_review: + for content_type, name, *_ in content_to_review: if name not in seen: seen.add(name) - type_counts["assignment"] += 1 + type_counts[content_type] += 1 rows = sorted(type_counts.items(), key=lambda r: (-r[1], r[0])) table = tabulate(rows, headers=["Resource Type", "Count"], tablefmt="presto") @@ -159,20 +159,12 @@ def format_notification( footer=footer, ) - # Error case: no fields, just the error in description - if data["error"]: - return Notification( - username=style["username"], - avatar_url=style["avatar_url"], - messages=[mb.build()], - ) - # -- Calculate max resource type length for formatting --------------------------------------------- grouped = defaultdict(int) for rtype, _, _ in data["deployed_content"]: grouped[rtype] += 1 - max_len = max(len(rtype) for rtype in grouped) + max_len = max((len(rtype) for rtype in grouped), default=0) # -- Overview table ------------------------------------------------------- if data["deployed_content"] or data["content_to_review"]: @@ -185,8 +177,8 @@ def format_notification( _format_item(content_type, name, max_len, url) for content_type, name, url in data["content_to_review"] ] - header = f"Needs review ({len(data['content_to_review'])})" - eb = _add_items_to_builder( + header = f"Needs Review ({len(data['content_to_review'])})" + _add_items_to_builder( eb, mb, header, lines, author_obj, footer, continuation_title, ) @@ -200,12 +192,12 @@ def format_notification( for content_type, name, url in remaining ] header = f"Remaining Resources ({len(remaining)})" - eb = _add_items_to_builder( + _add_items_to_builder( eb, mb, header, lines, author_obj, footer, continuation_title, ) return Notification( username=style["username"], avatar_url=style["avatar_url"], - messages=[mb.build()], + messages=mb.build(), ) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index 7d1b154..e81b73b 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from notifications.discord_limits import EmbedBuilder, MessageBuilder +from notifications.discord_limits import FIELD_VALUE_LIMIT, EmbedBuilder, MessageBuilder from notifications.formatting.formatting_utils import get_course_style, truncate_error from notifications.formatting.plain_text_utils import status_color from notifications.resources import Author, Footer, Notification @@ -17,6 +17,52 @@ def has_content(data) -> bool: def requires_review(data) -> bool: return bool(data["failed_images"] or data["error"]) +def _add_items_to_builder( + builder: EmbedBuilder, + message_builder: MessageBuilder, + header: str, + lines: list[str], + author: Author | None, + footer: Footer | None, + continuation_title: str, +): + """Pack item lines into fields, starting new embeds as needed.""" + current_lines: list[str] = [] + is_first_field = True + + def flush_field(): + nonlocal current_lines, is_first_field, builder + if not current_lines: + return + value = "\n".join(current_lines) + name = header if is_first_field else "\u200b" + + if not builder.can_add_field(name, value): + builder = message_builder.new_embed( + title=continuation_title, + description="", + footer=footer, + ) + + builder.add_field(name, value, inline=False) + is_first_field = False + current_lines = [] + + for line in lines: + test_value = "\n".join(current_lines + [line]) + test_name = header if is_first_field else "\u200b" + + would_exceed_field = len(test_value) > FIELD_VALUE_LIMIT + would_exceed_embed = not builder.can_add_field(test_name, test_value) + + if current_lines and (would_exceed_field or would_exceed_embed): + flush_field() + + current_lines.append(line) + + flush_field() + return builder + def format_notification( data, @@ -42,17 +88,27 @@ def format_notification( else: title = f"CS {course_id} | {course_name} -- Build complete" + continuation_title = f"{title} (continued)" + # -- Color ---------------------------------------------------------------- color = status_color( has_error=bool(data["error"]), needs_review=requires_review(data), ) + # -- Content message ------------------------------------------------------ + content = None + if data["error"] and cicd_role_id: + content = f"<@&{cicd_role_id}> ERROR -- Docker failed build(s). View [here]({action_url})" + elif data["failed_images"] and cicd_role_id: + content = f"<@&{cicd_role_id}> -- Docker build complete with failures -- review failed images" + # -- Description ---------------------------------------------------------- description = f"**Branch:** `{branch}`" + if data["error"]: truncated = truncate_error(data["error"]) - description += f"\n\n{truncated}" + description += f"\n\n**Error:**\n{truncated}" # -- Build message -------------------------------------------------------- mb = MessageBuilder(color=color, timestamp=timestamp) @@ -65,16 +121,20 @@ def format_notification( if data["failed_images"]: lines = [f"`{image}`" for image in data["failed_images"]] - value = "\n".join(lines) - eb.add_field(f"Failed ({len(data['failed_images'])})", value, inline=False) + header = f"Build(s) Failed ({len(data['failed_images'])})" + _add_items_to_builder( + eb, mb, header, lines, author_obj, footer, continuation_title, + ) if data["updated_images"]: lines = [f"`{image}`" for image in data["updated_images"]] - value = "\n".join(lines) - eb.add_field(f"Built ({len(data['updated_images'])})", value, inline=False) + header = f"Successfully Built ({len(data['updated_images'])})" + _add_items_to_builder( + eb, mb, header, lines, author_obj, footer, continuation_title, + ) return Notification( username=style["username"], avatar_url=style["avatar_url"], - messages=[mb.build()], + messages=mb.build(), ) diff --git a/notifications/formatting/plain_text_utils.py b/notifications/formatting/plain_text_utils.py index b42ea4a..177ec26 100644 --- a/notifications/formatting/plain_text_utils.py +++ b/notifications/formatting/plain_text_utils.py @@ -15,10 +15,10 @@ def status_color(*, has_error: bool, needs_review: bool) -> int: def dedupe_remaining_content( deployed_content: list[tuple[str, str, str | None]], - review_items: list[tuple[str, str]], + review_items: list[tuple[str, str, str | None]], ) -> list[tuple[str, str, str | None]]: - review_urls = {url for _, url in review_items if url} - review_labels = {label for label, _ in review_items} + 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() diff --git a/tests/send_test_notification.py b/tests/send_test_notification.py new file mode 100644 index 0000000..683d5ab --- /dev/null +++ b/tests/send_test_notification.py @@ -0,0 +1,40 @@ +""" +Send a test Discord notification from a JSON payload file. + +Usage: + source ~/.env + python send_test_notification.py --type canvas --payload path/to/payload.json + python send_test_notification.py --type docker --payload path/to/payload.json +""" + +import argparse +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from notifications.send_course import main as send_course + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Send a test Discord notification.") + parser.add_argument("--type", required=True, choices=["canvas", "docker"]) + parser.add_argument("--payload", required=True, help="Path to payload JSON file") + args = parser.parse_args() + + if not os.path.isfile(args.payload): + print(f"Error: payload file not found at {args.payload}", file=sys.stderr) + sys.exit(1) + + send_course( + ntype=args.type, + payload=args.payload, + course_id="000", + course_name="Test Course", + course_url="https://example.com", + author="test-user", + author_icon="https://github.com/ghost.png", + branch_name="test-branch", + action_url="https://github.com/actions/runs/0", + cicd_role_id=None, + ) diff --git a/tests/test-docker-payload.json b/tests/test-docker-payload.json new file mode 100644 index 0000000..bac7149 --- /dev/null +++ b/tests/test-docker-payload.json @@ -0,0 +1,507 @@ +{ + "updated_images": [ + "build_test_1_docker.sh", + "build_test_2_docker.sh", + "build_test_3_docker.sh", + "build_test_4_docker.sh", + "build_test_5_docker.sh", + "build_test_6_docker.sh", + "build_test_7_docker.sh", + "build_test_8_docker.sh", + "build_test_9_docker.sh", + "build_test_10_docker.sh", + "build_test_11_docker.sh", + "build_test_12_docker.sh", + "build_test_13_docker.sh", + "build_test_14_docker.sh", + "build_test_15_docker.sh", + "build_test_16_docker.sh", + "build_test_17_docker.sh", + "build_test_18_docker.sh", + "build_test_19_docker.sh", + "build_test_20_docker.sh", + "build_test_21_docker.sh", + "build_test_22_docker.sh", + "build_test_23_docker.sh", + "build_test_24_docker.sh", + "build_test_25_docker.sh", + "build_test_26_docker.sh", + "build_test_27_docker.sh", + "build_test_28_docker.sh", + "build_test_29_docker.sh", + "build_test_30_docker.sh", + "build_test_31_docker.sh", + "build_test_32_docker.sh", + "build_test_33_docker.sh", + "build_test_34_docker.sh", + "build_test_35_docker.sh", + "build_test_36_docker.sh", + "build_test_37_docker.sh", + "build_test_38_docker.sh", + "build_test_39_docker.sh", + "build_test_40_docker.sh", + "build_test_41_docker.sh", + "build_test_42_docker.sh", + "build_test_43_docker.sh", + "build_test_44_docker.sh", + "build_test_45_docker.sh", + "build_test_46_docker.sh", + "build_test_47_docker.sh", + "build_test_48_docker.sh", + "build_test_49_docker.sh", + "build_test_50_docker.sh", + "build_test_51_docker.sh", + "build_test_52_docker.sh", + "build_test_53_docker.sh", + "build_test_54_docker.sh", + "build_test_55_docker.sh", + "build_test_56_docker.sh", + "build_test_57_docker.sh", + "build_test_58_docker.sh", + "build_test_59_docker.sh", + "build_test_60_docker.sh", + "build_test_61_docker.sh", + "build_test_62_docker.sh", + "build_test_63_docker.sh", + "build_test_64_docker.sh", + "build_test_65_docker.sh", + "build_test_66_docker.sh", + "build_test_67_docker.sh", + "build_test_68_docker.sh", + "build_test_69_docker.sh", + "build_test_70_docker.sh", + "build_test_71_docker.sh", + "build_test_72_docker.sh", + "build_test_73_docker.sh", + "build_test_74_docker.sh", + "build_test_75_docker.sh", + "build_test_76_docker.sh", + "build_test_77_docker.sh", + "build_test_78_docker.sh", + "build_test_79_docker.sh", + "build_test_80_docker.sh", + "build_test_81_docker.sh", + "build_test_82_docker.sh", + "build_test_83_docker.sh", + "build_test_84_docker.sh", + "build_test_85_docker.sh", + "build_test_86_docker.sh", + "build_test_87_docker.sh", + "build_test_88_docker.sh", + "build_test_89_docker.sh", + "build_test_90_docker.sh", + "build_test_91_docker.sh", + "build_test_92_docker.sh", + "build_test_93_docker.sh", + "build_test_94_docker.sh", + "build_test_95_docker.sh", + "build_test_96_docker.sh", + "build_test_97_docker.sh", + "build_test_98_docker.sh", + "build_test_99_docker.sh", + "build_test_100_docker.sh", + "build_test_1_docker.sh", + "build_test_2_docker.sh", + "build_test_3_docker.sh", + "build_test_4_docker.sh", + "build_test_5_docker.sh", + "build_test_6_docker.sh", + "build_test_7_docker.sh", + "build_test_8_docker.sh", + "build_test_9_docker.sh", + "build_test_10_docker.sh", + "build_test_11_docker.sh", + "build_test_12_docker.sh", + "build_test_13_docker.sh", + "build_test_14_docker.sh", + "build_test_15_docker.sh", + "build_test_16_docker.sh", + "build_test_17_docker.sh", + "build_test_18_docker.sh", + "build_test_19_docker.sh", + "build_test_20_docker.sh", + "build_test_21_docker.sh", + "build_test_22_docker.sh", + "build_test_23_docker.sh", + "build_test_24_docker.sh", + "build_test_25_docker.sh", + "build_test_26_docker.sh", + "build_test_27_docker.sh", + "build_test_28_docker.sh", + "build_test_29_docker.sh", + "build_test_30_docker.sh", + "build_test_31_docker.sh", + "build_test_32_docker.sh", + "build_test_33_docker.sh", + "build_test_34_docker.sh", + "build_test_35_docker.sh", + "build_test_36_docker.sh", + "build_test_37_docker.sh", + "build_test_38_docker.sh", + "build_test_39_docker.sh", + "build_test_40_docker.sh", + "build_test_41_docker.sh", + "build_test_42_docker.sh", + "build_test_43_docker.sh", + "build_test_44_docker.sh", + "build_test_45_docker.sh", + "build_test_46_docker.sh", + "build_test_47_docker.sh", + "build_test_48_docker.sh", + "build_test_49_docker.sh", + "build_test_50_docker.sh", + "build_test_51_docker.sh", + "build_test_52_docker.sh", + "build_test_53_docker.sh", + "build_test_54_docker.sh", + "build_test_55_docker.sh", + "build_test_56_docker.sh", + "build_test_57_docker.sh", + "build_test_58_docker.sh", + "build_test_59_docker.sh", + "build_test_60_docker.sh", + "build_test_61_docker.sh", + "build_test_62_docker.sh", + "build_test_63_docker.sh", + "build_test_64_docker.sh", + "build_test_65_docker.sh", + "build_test_66_docker.sh", + "build_test_67_docker.sh", + "build_test_68_docker.sh", + "build_test_69_docker.sh", + "build_test_70_docker.sh", + "build_test_71_docker.sh", + "build_test_72_docker.sh", + "build_test_73_docker.sh", + "build_test_74_docker.sh", + "build_test_75_docker.sh", + "build_test_76_docker.sh", + "build_test_77_docker.sh", + "build_test_78_docker.sh", + "build_test_79_docker.sh", + "build_test_80_docker.sh", + "build_test_81_docker.sh", + "build_test_82_docker.sh", + "build_test_83_docker.sh", + "build_test_84_docker.sh", + "build_test_85_docker.sh", + "build_test_86_docker.sh", + "build_test_87_docker.sh", + "build_test_88_docker.sh", + "build_test_89_docker.sh", + "build_test_90_docker.sh", + "build_test_91_docker.sh", + "build_test_92_docker.sh", + "build_test_93_docker.sh", + "build_test_94_docker.sh", + "build_test_95_docker.sh", + "build_test_96_docker.sh", + "build_test_97_docker.sh", + "build_test_98_docker.sh", + "build_test_99_docker.sh", + "build_test_100_docker.sh", + "build_test_1_docker.sh", + "build_test_2_docker.sh", + "build_test_3_docker.sh", + "build_test_4_docker.sh", + "build_test_5_docker.sh", + "build_test_6_docker.sh", + "build_test_7_docker.sh", + "build_test_8_docker.sh", + "build_test_9_docker.sh", + "build_test_10_docker.sh", + "build_test_11_docker.sh", + "build_test_12_docker.sh", + "build_test_13_docker.sh", + "build_test_14_docker.sh", + "build_test_15_docker.sh", + "build_test_16_docker.sh", + "build_test_17_docker.sh", + "build_test_18_docker.sh", + "build_test_19_docker.sh", + "build_test_20_docker.sh", + "build_test_21_docker.sh", + "build_test_22_docker.sh", + "build_test_23_docker.sh", + "build_test_24_docker.sh", + "build_test_25_docker.sh", + "build_test_26_docker.sh", + "build_test_27_docker.sh", + "build_test_28_docker.sh", + "build_test_29_docker.sh", + "build_test_30_docker.sh", + "build_test_31_docker.sh", + "build_test_32_docker.sh", + "build_test_33_docker.sh", + "build_test_34_docker.sh", + "build_test_35_docker.sh", + "build_test_36_docker.sh", + "build_test_37_docker.sh", + "build_test_38_docker.sh", + "build_test_39_docker.sh", + "build_test_40_docker.sh", + "build_test_41_docker.sh", + "build_test_42_docker.sh", + "build_test_43_docker.sh", + "build_test_44_docker.sh", + "build_test_45_docker.sh", + "build_test_46_docker.sh", + "build_test_47_docker.sh", + "build_test_48_docker.sh", + "build_test_49_docker.sh", + "build_test_50_docker.sh", + "build_test_51_docker.sh", + "build_test_52_docker.sh", + "build_test_53_docker.sh", + "build_test_54_docker.sh", + "build_test_55_docker.sh", + "build_test_56_docker.sh", + "build_test_57_docker.sh", + "build_test_58_docker.sh", + "build_test_59_docker.sh", + "build_test_60_docker.sh", + "build_test_61_docker.sh", + "build_test_62_docker.sh", + "build_test_63_docker.sh", + "build_test_64_docker.sh", + "build_test_65_docker.sh", + "build_test_66_docker.sh", + "build_test_67_docker.sh", + "build_test_68_docker.sh", + "build_test_69_docker.sh", + "build_test_70_docker.sh", + "build_test_71_docker.sh", + "build_test_72_docker.sh", + "build_test_73_docker.sh", + "build_test_74_docker.sh", + "build_test_75_docker.sh", + "build_test_76_docker.sh", + "build_test_77_docker.sh", + "build_test_78_docker.sh", + "build_test_79_docker.sh", + "build_test_80_docker.sh", + "build_test_81_docker.sh", + "build_test_82_docker.sh", + "build_test_83_docker.sh", + "build_test_84_docker.sh", + "build_test_85_docker.sh", + "build_test_86_docker.sh", + "build_test_87_docker.sh", + "build_test_88_docker.sh", + "build_test_89_docker.sh", + "build_test_90_docker.sh", + "build_test_91_docker.sh", + "build_test_92_docker.sh", + "build_test_93_docker.sh", + "build_test_94_docker.sh", + "build_test_95_docker.sh", + "build_test_96_docker.sh", + "build_test_97_docker.sh", + "build_test_98_docker.sh", + "build_test_99_docker.sh", + "build_test_100_docker.sh", + "build_test_1_docker.sh", + "build_test_2_docker.sh", + "build_test_3_docker.sh", + "build_test_4_docker.sh", + "build_test_5_docker.sh", + "build_test_6_docker.sh", + "build_test_7_docker.sh", + "build_test_8_docker.sh", + "build_test_9_docker.sh", + "build_test_10_docker.sh", + "build_test_11_docker.sh", + "build_test_12_docker.sh", + "build_test_13_docker.sh", + "build_test_14_docker.sh", + "build_test_15_docker.sh", + "build_test_16_docker.sh", + "build_test_17_docker.sh", + "build_test_18_docker.sh", + "build_test_19_docker.sh", + "build_test_20_docker.sh", + "build_test_21_docker.sh", + "build_test_22_docker.sh", + "build_test_23_docker.sh", + "build_test_24_docker.sh", + "build_test_25_docker.sh", + "build_test_26_docker.sh", + "build_test_27_docker.sh", + "build_test_28_docker.sh", + "build_test_29_docker.sh", + "build_test_30_docker.sh", + "build_test_31_docker.sh", + "build_test_32_docker.sh", + "build_test_33_docker.sh", + "build_test_34_docker.sh", + "build_test_35_docker.sh", + "build_test_36_docker.sh", + "build_test_37_docker.sh", + "build_test_38_docker.sh", + "build_test_39_docker.sh", + "build_test_40_docker.sh", + "build_test_41_docker.sh", + "build_test_42_docker.sh", + "build_test_43_docker.sh", + "build_test_44_docker.sh", + "build_test_45_docker.sh", + "build_test_46_docker.sh", + "build_test_47_docker.sh", + "build_test_48_docker.sh", + "build_test_49_docker.sh", + "build_test_50_docker.sh", + "build_test_51_docker.sh", + "build_test_52_docker.sh", + "build_test_53_docker.sh", + "build_test_54_docker.sh", + "build_test_55_docker.sh", + "build_test_56_docker.sh", + "build_test_57_docker.sh", + "build_test_58_docker.sh", + "build_test_59_docker.sh", + "build_test_60_docker.sh", + "build_test_61_docker.sh", + "build_test_62_docker.sh", + "build_test_63_docker.sh", + "build_test_64_docker.sh", + "build_test_65_docker.sh", + "build_test_66_docker.sh", + "build_test_67_docker.sh", + "build_test_68_docker.sh", + "build_test_69_docker.sh", + "build_test_70_docker.sh", + "build_test_71_docker.sh", + "build_test_72_docker.sh", + "build_test_73_docker.sh", + "build_test_74_docker.sh", + "build_test_75_docker.sh", + "build_test_76_docker.sh", + "build_test_77_docker.sh", + "build_test_78_docker.sh", + "build_test_79_docker.sh", + "build_test_80_docker.sh", + "build_test_81_docker.sh", + "build_test_82_docker.sh", + "build_test_83_docker.sh", + "build_test_84_docker.sh", + "build_test_85_docker.sh", + "build_test_86_docker.sh", + "build_test_87_docker.sh", + "build_test_88_docker.sh", + "build_test_89_docker.sh", + "build_test_90_docker.sh", + "build_test_91_docker.sh", + "build_test_92_docker.sh", + "build_test_93_docker.sh", + "build_test_94_docker.sh", + "build_test_95_docker.sh", + "build_test_96_docker.sh", + "build_test_97_docker.sh", + "build_test_98_docker.sh", + "build_test_99_docker.sh", + "build_test_100_docker.sh" + ], + "failed_images": [ + "build_test_1_docker.sh", + "build_test_2_docker.sh", + "build_test_3_docker.sh", + "build_test_4_docker.sh", + "build_test_5_docker.sh", + "build_test_6_docker.sh", + "build_test_7_docker.sh", + "build_test_8_docker.sh", + "build_test_9_docker.sh", + "build_test_10_docker.sh", + "build_test_11_docker.sh", + "build_test_12_docker.sh", + "build_test_13_docker.sh", + "build_test_14_docker.sh", + "build_test_15_docker.sh", + "build_test_16_docker.sh", + "build_test_17_docker.sh", + "build_test_18_docker.sh", + "build_test_19_docker.sh", + "build_test_20_docker.sh", + "build_test_21_docker.sh", + "build_test_22_docker.sh", + "build_test_23_docker.sh", + "build_test_24_docker.sh", + "build_test_25_docker.sh", + "build_test_26_docker.sh", + "build_test_27_docker.sh", + "build_test_28_docker.sh", + "build_test_29_docker.sh", + "build_test_30_docker.sh", + "build_test_31_docker.sh", + "build_test_32_docker.sh", + "build_test_33_docker.sh", + "build_test_34_docker.sh", + "build_test_35_docker.sh", + "build_test_36_docker.sh", + "build_test_37_docker.sh", + "build_test_38_docker.sh", + "build_test_39_docker.sh", + "build_test_40_docker.sh", + "build_test_41_docker.sh", + "build_test_42_docker.sh", + "build_test_43_docker.sh", + "build_test_44_docker.sh", + "build_test_45_docker.sh", + "build_test_46_docker.sh", + "build_test_47_docker.sh", + "build_test_48_docker.sh", + "build_test_49_docker.sh", + "build_test_50_docker.sh", + "build_test_51_docker.sh", + "build_test_52_docker.sh", + "build_test_53_docker.sh", + "build_test_54_docker.sh", + "build_test_55_docker.sh", + "build_test_56_docker.sh", + "build_test_57_docker.sh", + "build_test_58_docker.sh", + "build_test_59_docker.sh", + "build_test_60_docker.sh", + "build_test_61_docker.sh", + "build_test_62_docker.sh", + "build_test_63_docker.sh", + "build_test_64_docker.sh", + "build_test_65_docker.sh", + "build_test_66_docker.sh", + "build_test_67_docker.sh", + "build_test_68_docker.sh", + "build_test_69_docker.sh", + "build_test_70_docker.sh", + "build_test_71_docker.sh", + "build_test_72_docker.sh", + "build_test_73_docker.sh", + "build_test_74_docker.sh", + "build_test_75_docker.sh", + "build_test_76_docker.sh", + "build_test_77_docker.sh", + "build_test_78_docker.sh", + "build_test_79_docker.sh", + "build_test_80_docker.sh", + "build_test_81_docker.sh", + "build_test_82_docker.sh", + "build_test_83_docker.sh", + "build_test_84_docker.sh", + "build_test_85_docker.sh", + "build_test_86_docker.sh", + "build_test_87_docker.sh", + "build_test_88_docker.sh", + "build_test_89_docker.sh", + "build_test_90_docker.sh", + "build_test_91_docker.sh", + "build_test_92_docker.sh", + "build_test_93_docker.sh", + "build_test_94_docker.sh", + "build_test_95_docker.sh", + "build_test_96_docker.sh", + "build_test_97_docker.sh", + "build_test_98_docker.sh", + "build_test_99_docker.sh", + "build_test_100_docker.sh" + ], + "error": "" +} \ No newline at end of file diff --git a/tests/test-error-docker-payload.json b/tests/test-error-docker-payload.json new file mode 100644 index 0000000..20597b6 --- /dev/null +++ b/tests/test-error-docker-payload.json @@ -0,0 +1,5 @@ +{ + "updated_images": [], + "failed_images": [], + "error": "JSONDecodeError('Expecting value: line 1 column 1 (char 0)')" +} \ No newline at end of file diff --git a/tests/test-error-mdxcanvas-payload.json b/tests/test-error-mdxcanvas-payload.json new file mode 100644 index 0000000..68762f7 --- /dev/null +++ b/tests/test-error-mdxcanvas-payload.json @@ -0,0 +1,17 @@ +{ + "deployed_content": [ + [ + "quiz_question_order", + "day-2-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ] + ], + "content_to_review": [ + [ + "quiz_question_order", + "day-4-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ] + ], + "error": "JSONDecodeError: Expecting value: line 1 column 1 (char 0)" +} \ No newline at end of file diff --git a/tests/test-large-mdxcanvas-payload.json b/tests/test-large-mdxcanvas-payload.json new file mode 100644 index 0000000..a2c248a --- /dev/null +++ b/tests/test-large-mdxcanvas-payload.json @@ -0,0 +1,6151 @@ +{ + "deployed_content": [ + [ + "module", + "unit-1", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "unit-2", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "unit-3", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "unit-4", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "comprehensive-review", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-01", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-02", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-03", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-04", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-05", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-06", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-07", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-08", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-09", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-10", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-11", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-12", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-13", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-14", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-15", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-16", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-17", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-18", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-19", + "https://byu.instructure.com/courses/20736" + ], + [ + "module", + "load-test-module-20", + "https://byu.instructure.com/courses/20736" + ], + [ + "module_item", + "load-test-module-20|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-14|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "page", + "load-test-page-161", + "https://byu.instructure.com/courses/20736/pages/load-test-page-161-coastal-resilience-2" + ], + [ + "page", + "load-test-page-131", + "https://byu.instructure.com/courses/20736/pages/load-test-page-131-current-driven-dispersal-2" + ], + [ + "page", + "load-test-page-191", + "https://byu.instructure.com/courses/20736/pages/load-test-page-191-current-driven-dispersal-2" + ], + [ + "module_item", + "load-test-module-17|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "page", + "load-test-page-181", + "https://byu.instructure.com/courses/20736/pages/load-test-page-181-predator-prey-balance-2" + ], + [ + "page", + "load-test-page-151", + "https://byu.instructure.com/courses/20736/pages/load-test-page-151-food-web-transfer-2" + ], + [ + "page", + "load-test-page-171", + "https://byu.instructure.com/courses/20736/pages/load-test-page-171-coastal-resilience-2" + ], + [ + "module_item", + "load-test-module-18|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-15|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-19|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "page", + "load-test-page-141", + "https://byu.instructure.com/courses/20736/pages/load-test-page-141-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-16|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "page", + "load-test-page-111", + "https://byu.instructure.com/courses/20736/pages/load-test-page-111-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-13|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "page", + "load-test-page-121", + "https://byu.instructure.com/courses/20736/pages/load-test-page-121-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-12|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "page", + "load-test-page-091", + "https://byu.instructure.com/courses/20736/pages/load-test-page-091-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-11|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "page", + "load-test-page-101", + "https://byu.instructure.com/courses/20736/pages/load-test-page-101-marine-conservation-policy-2" + ], + [ + "page", + "load-test-page-081", + "https://byu.instructure.com/courses/20736/pages/load-test-page-081-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-09|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "module_item", + "load-test-module-10|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "page", + "load-test-page-071", + "https://byu.instructure.com/courses/20736/pages/load-test-page-071-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-08|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "page", + "load-test-page-061", + "https://byu.instructure.com/courses/20736/pages/load-test-page-061-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-06|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "page", + "load-test-page-041", + "https://byu.instructure.com/courses/20736/pages/load-test-page-041-current-driven-dispersal-2" + ], + [ + "module_item", + "load-test-module-07|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-05|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "page", + "load-test-page-051", + "https://byu.instructure.com/courses/20736/pages/load-test-page-051-predator-prey-balance-2" + ], + [ + "page", + "load-test-page-011", + "https://byu.instructure.com/courses/20736/pages/load-test-page-011-reef-restoration-2" + ], + [ + "page", + "load-test-page-021", + "https://byu.instructure.com/courses/20736/pages/load-test-page-021-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-04|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "page", + "load-test-page-031", + "https://byu.instructure.com/courses/20736/pages/load-test-page-031-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-02|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-03|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "page", + "load-test-page-192", + "https://byu.instructure.com/courses/20736/pages/load-test-page-192-nutrient-cycling-2" + ], + [ + "page", + "load-test-page-182", + "https://byu.instructure.com/courses/20736/pages/load-test-page-182-benthic-biodiversity-2" + ], + [ + "module_item", + "load-test-module-20|load-test-page-191", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-19|load-test-page-181", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "page", + "load-test-page-172", + "https://byu.instructure.com/courses/20736/pages/load-test-page-172-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-01|Synthetic Pages", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "page", + "load-test-page-001", + "https://byu.instructure.com/courses/20736/pages/load-test-page-001-marine-conservation-policy-2" + ], + [ + "page", + "load-test-page-162", + "https://byu.instructure.com/courses/20736/pages/load-test-page-162-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-18|load-test-page-171", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "page", + "load-test-page-152", + "https://byu.instructure.com/courses/20736/pages/load-test-page-152-predator-prey-balance-2" + ], + [ + "module_item", + "load-test-module-17|load-test-page-161", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-16|load-test-page-151", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "page", + "load-test-page-142", + "https://byu.instructure.com/courses/20736/pages/load-test-page-142-coastal-resilience-2" + ], + [ + "page", + "load-test-page-132", + "https://byu.instructure.com/courses/20736/pages/load-test-page-132-benthic-biodiversity-2" + ], + [ + "page", + "load-test-page-122", + "https://byu.instructure.com/courses/20736/pages/load-test-page-122-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-15|load-test-page-141", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-13|load-test-page-121", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "page", + "load-test-page-092", + "https://byu.instructure.com/courses/20736/pages/load-test-page-092-marine-conservation-policy-2" + ], + [ + "page", + "load-test-page-102", + "https://byu.instructure.com/courses/20736/pages/load-test-page-102-predator-prey-balance-2" + ], + [ + "module_item", + "load-test-module-14|load-test-page-131", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-10|load-test-page-091", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "page", + "load-test-page-112", + "https://byu.instructure.com/courses/20736/pages/load-test-page-112-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-12|load-test-page-111", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "module_item", + "load-test-module-11|load-test-page-101", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "page", + "load-test-page-082", + "https://byu.instructure.com/courses/20736/pages/load-test-page-082-predator-prey-balance-2" + ], + [ + "page", + "load-test-page-072", + "https://byu.instructure.com/courses/20736/pages/load-test-page-072-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-08|load-test-page-071", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "page", + "load-test-page-062", + "https://byu.instructure.com/courses/20736/pages/load-test-page-062-benthic-biodiversity-2" + ], + [ + "page", + "load-test-page-052", + "https://byu.instructure.com/courses/20736/pages/load-test-page-052-marine-conservation-policy-2" + ], + [ + "module_item", + "load-test-module-09|load-test-page-081", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "page", + "load-test-page-042", + "https://byu.instructure.com/courses/20736/pages/load-test-page-042-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-06|load-test-page-051", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "page", + "load-test-page-022", + "https://byu.instructure.com/courses/20736/pages/load-test-page-022-coastal-resilience-2" + ], + [ + "module_item", + "load-test-module-05|load-test-page-041", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "page", + "load-test-page-032", + "https://byu.instructure.com/courses/20736/pages/load-test-page-032-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-07|load-test-page-061", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-04|load-test-page-031", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "module_item", + "load-test-module-03|load-test-page-021", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "page", + "load-test-page-012", + "https://byu.instructure.com/courses/20736/pages/load-test-page-012-reef-restoration-2" + ], + [ + "page", + "load-test-page-002", + "https://byu.instructure.com/courses/20736/pages/load-test-page-002-nutrient-cycling-2" + ], + [ + "page", + "load-test-page-193", + "https://byu.instructure.com/courses/20736/pages/load-test-page-193-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-01|load-test-page-001", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "load-test-module-20|load-test-page-192", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-02|load-test-page-011", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "page", + "load-test-page-163", + "https://byu.instructure.com/courses/20736/pages/load-test-page-163-deep-sea-adaptation-2" + ], + [ + "page", + "load-test-page-183", + "https://byu.instructure.com/courses/20736/pages/load-test-page-183-ocean-chemistry-2" + ], + [ + "module_item", + "load-test-module-18|load-test-page-172", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "page", + "load-test-page-173", + "https://byu.instructure.com/courses/20736/pages/load-test-page-173-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-17|load-test-page-162", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "page", + "load-test-page-153", + "https://byu.instructure.com/courses/20736/pages/load-test-page-153-benthic-biodiversity-2" + ], + [ + "module_item", + "load-test-module-16|load-test-page-152", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "page", + "load-test-page-133", + "https://byu.instructure.com/courses/20736/pages/load-test-page-133-current-driven-dispersal-2" + ], + [ + "module_item", + "load-test-module-19|load-test-page-182", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "page", + "load-test-page-143", + "https://byu.instructure.com/courses/20736/pages/load-test-page-143-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-14|load-test-page-132", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-15|load-test-page-142", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "page", + "load-test-page-123", + "https://byu.instructure.com/courses/20736/pages/load-test-page-123-current-driven-dispersal-2" + ], + [ + "page", + "load-test-page-113", + "https://byu.instructure.com/courses/20736/pages/load-test-page-113-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-13|load-test-page-122", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "page", + "load-test-page-103", + "https://byu.instructure.com/courses/20736/pages/load-test-page-103-plankton-dynamics-2" + ], + [ + "page", + "load-test-page-093", + "https://byu.instructure.com/courses/20736/pages/load-test-page-093-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-09|load-test-page-082", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "page", + "load-test-page-083", + "https://byu.instructure.com/courses/20736/pages/load-test-page-083-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-11|load-test-page-102", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-12|load-test-page-112", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "page", + "load-test-page-073", + "https://byu.instructure.com/courses/20736/pages/load-test-page-073-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-10|load-test-page-092", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-08|load-test-page-072", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "page", + "load-test-page-063", + "https://byu.instructure.com/courses/20736/pages/load-test-page-063-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-053", + "https://byu.instructure.com/courses/20736/pages/load-test-page-053-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-07|load-test-page-062", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "page", + "load-test-page-043", + "https://byu.instructure.com/courses/20736/pages/load-test-page-043-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-06|load-test-page-052", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "page", + "load-test-page-033", + "https://byu.instructure.com/courses/20736/pages/load-test-page-033-deep-sea-adaptation-2" + ], + [ + "page", + "load-test-page-013", + "https://byu.instructure.com/courses/20736/pages/load-test-page-013-ocean-chemistry-2" + ], + [ + "module_item", + "load-test-module-05|load-test-page-042", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "page", + "load-test-page-023", + "https://byu.instructure.com/courses/20736/pages/load-test-page-023-ocean-chemistry-2" + ], + [ + "module_item", + "load-test-module-04|load-test-page-032", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "module_item", + "load-test-module-03|load-test-page-022", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "page", + "load-test-page-194", + "https://byu.instructure.com/courses/20736/pages/load-test-page-194-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-02|load-test-page-012", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-19|load-test-page-183", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "page", + "load-test-page-184", + "https://byu.instructure.com/courses/20736/pages/load-test-page-184-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-003", + "https://byu.instructure.com/courses/20736/pages/load-test-page-003-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-01|load-test-page-002", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "load-test-module-20|load-test-page-193", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "page", + "load-test-page-174", + "https://byu.instructure.com/courses/20736/pages/load-test-page-174-reef-restoration-2" + ], + [ + "page", + "load-test-page-154", + "https://byu.instructure.com/courses/20736/pages/load-test-page-154-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-18|load-test-page-173", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "page", + "load-test-page-164", + "https://byu.instructure.com/courses/20736/pages/load-test-page-164-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-17|load-test-page-163", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-16|load-test-page-153", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "page", + "load-test-page-144", + "https://byu.instructure.com/courses/20736/pages/load-test-page-144-marine-conservation-policy-2" + ], + [ + "page", + "load-test-page-134", + "https://byu.instructure.com/courses/20736/pages/load-test-page-134-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-114", + "https://byu.instructure.com/courses/20736/pages/load-test-page-114-deep-sea-adaptation-2" + ], + [ + "page", + "load-test-page-124", + "https://byu.instructure.com/courses/20736/pages/load-test-page-124-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-15|load-test-page-143", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "page", + "load-test-page-104", + "https://byu.instructure.com/courses/20736/pages/load-test-page-104-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-13|load-test-page-123", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "module_item", + "load-test-module-14|load-test-page-133", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-12|load-test-page-113", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "module_item", + "load-test-module-11|load-test-page-103", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "page", + "load-test-page-084", + "https://byu.instructure.com/courses/20736/pages/load-test-page-084-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-10|load-test-page-093", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-09|load-test-page-083", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "page", + "load-test-page-094", + "https://byu.instructure.com/courses/20736/pages/load-test-page-094-deep-sea-adaptation-2" + ], + [ + "page", + "load-test-page-074", + "https://byu.instructure.com/courses/20736/pages/load-test-page-074-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-054", + "https://byu.instructure.com/courses/20736/pages/load-test-page-054-current-driven-dispersal-2" + ], + [ + "page", + "load-test-page-064", + "https://byu.instructure.com/courses/20736/pages/load-test-page-064-migration-behavior-2" + ], + [ + "page", + "load-test-page-044", + "https://byu.instructure.com/courses/20736/pages/load-test-page-044-predator-prey-balance-2" + ], + [ + "module_item", + "load-test-module-07|load-test-page-063", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "page", + "load-test-page-034", + "https://byu.instructure.com/courses/20736/pages/load-test-page-034-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-05|load-test-page-043", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "module_item", + "load-test-module-06|load-test-page-053", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-08|load-test-page-073", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "page", + "load-test-page-024", + "https://byu.instructure.com/courses/20736/pages/load-test-page-024-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-04|load-test-page-033", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "module_item", + "load-test-module-03|load-test-page-023", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "page", + "load-test-page-014", + "https://byu.instructure.com/courses/20736/pages/load-test-page-014-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-004", + "https://byu.instructure.com/courses/20736/pages/load-test-page-004-ocean-chemistry-2" + ], + [ + "module_item", + "load-test-module-02|load-test-page-013", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-01|load-test-page-003", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "page", + "load-test-page-195", + "https://byu.instructure.com/courses/20736/pages/load-test-page-195-benthic-biodiversity-2" + ], + [ + "page", + "load-test-page-175", + "https://byu.instructure.com/courses/20736/pages/load-test-page-175-benthic-biodiversity-2" + ], + [ + "module_item", + "load-test-module-19|load-test-page-184", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "page", + "load-test-page-165", + "https://byu.instructure.com/courses/20736/pages/load-test-page-165-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-20|load-test-page-194", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "page", + "load-test-page-155", + "https://byu.instructure.com/courses/20736/pages/load-test-page-155-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-18|load-test-page-174", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-17|load-test-page-164", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "page", + "load-test-page-185", + "https://byu.instructure.com/courses/20736/pages/load-test-page-185-migration-behavior-2" + ], + [ + "page", + "load-test-page-145", + "https://byu.instructure.com/courses/20736/pages/load-test-page-145-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-125", + "https://byu.instructure.com/courses/20736/pages/load-test-page-125-ocean-chemistry-2" + ], + [ + "module_item", + "load-test-module-16|load-test-page-154", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "page", + "load-test-page-135", + "https://byu.instructure.com/courses/20736/pages/load-test-page-135-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-15|load-test-page-144", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "page", + "load-test-page-105", + "https://byu.instructure.com/courses/20736/pages/load-test-page-105-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-14|load-test-page-134", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "page", + "load-test-page-115", + "https://byu.instructure.com/courses/20736/pages/load-test-page-115-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-13|load-test-page-124", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "module_item", + "load-test-module-12|load-test-page-114", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "module_item", + "load-test-module-11|load-test-page-104", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "page", + "load-test-page-095", + "https://byu.instructure.com/courses/20736/pages/load-test-page-095-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-10|load-test-page-094", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "page", + "load-test-page-085", + "https://byu.instructure.com/courses/20736/pages/load-test-page-085-coastal-resilience-2" + ], + [ + "page", + "load-test-page-075", + "https://byu.instructure.com/courses/20736/pages/load-test-page-075-migration-behavior-2" + ], + [ + "page", + "load-test-page-065", + "https://byu.instructure.com/courses/20736/pages/load-test-page-065-marine-conservation-policy-2" + ], + [ + "module_item", + "load-test-module-09|load-test-page-084", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "page", + "load-test-page-055", + "https://byu.instructure.com/courses/20736/pages/load-test-page-055-marine-conservation-policy-2" + ], + [ + "page", + "load-test-page-045", + "https://byu.instructure.com/courses/20736/pages/load-test-page-045-ocean-chemistry-2" + ], + [ + "module_item", + "load-test-module-08|load-test-page-074", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-07|load-test-page-064", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "page", + "load-test-page-035", + "https://byu.instructure.com/courses/20736/pages/load-test-page-035-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-05|load-test-page-044", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "page", + "load-test-page-025", + "https://byu.instructure.com/courses/20736/pages/load-test-page-025-marine-conservation-policy-2" + ], + [ + "module_item", + "load-test-module-06|load-test-page-054", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-04|load-test-page-034", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "page", + "load-test-page-015", + "https://byu.instructure.com/courses/20736/pages/load-test-page-015-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-02|load-test-page-014", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "page", + "load-test-page-196", + "https://byu.instructure.com/courses/20736/pages/load-test-page-196-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-19|load-test-page-185", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "module_item", + "load-test-module-20|load-test-page-195", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "page", + "load-test-page-186", + "https://byu.instructure.com/courses/20736/pages/load-test-page-186-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-005", + "https://byu.instructure.com/courses/20736/pages/load-test-page-005-plankton-dynamics-2" + ], + [ + "page", + "load-test-page-166", + "https://byu.instructure.com/courses/20736/pages/load-test-page-166-coastal-resilience-2" + ], + [ + "module_item", + "load-test-module-01|load-test-page-004", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "page", + "load-test-page-176", + "https://byu.instructure.com/courses/20736/pages/load-test-page-176-ocean-chemistry-2" + ], + [ + "module_item", + "load-test-module-03|load-test-page-024", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "module_item", + "load-test-module-18|load-test-page-175", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-17|load-test-page-165", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-16|load-test-page-155", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "page", + "load-test-page-156", + "https://byu.instructure.com/courses/20736/pages/load-test-page-156-predator-prey-balance-2" + ], + [ + "page", + "load-test-page-136", + "https://byu.instructure.com/courses/20736/pages/load-test-page-136-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-15|load-test-page-145", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "page", + "load-test-page-126", + "https://byu.instructure.com/courses/20736/pages/load-test-page-126-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-14|load-test-page-135", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "page", + "load-test-page-106", + "https://byu.instructure.com/courses/20736/pages/load-test-page-106-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-12|load-test-page-115", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "page", + "load-test-page-096", + "https://byu.instructure.com/courses/20736/pages/load-test-page-096-food-web-transfer-2" + ], + [ + "page", + "load-test-page-116", + "https://byu.instructure.com/courses/20736/pages/load-test-page-116-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-11|load-test-page-105", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-13|load-test-page-125", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "module_item", + "load-test-module-10|load-test-page-095", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-09|load-test-page-085", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "page", + "load-test-page-086", + "https://byu.instructure.com/courses/20736/pages/load-test-page-086-marine-conservation-policy-2" + ], + [ + "page", + "load-test-page-056", + "https://byu.instructure.com/courses/20736/pages/load-test-page-056-benthic-biodiversity-2" + ], + [ + "page", + "load-test-page-076", + "https://byu.instructure.com/courses/20736/pages/load-test-page-076-benthic-biodiversity-2" + ], + [ + "page", + "load-test-page-046", + "https://byu.instructure.com/courses/20736/pages/load-test-page-046-nutrient-cycling-2" + ], + [ + "page", + "load-test-page-066", + "https://byu.instructure.com/courses/20736/pages/load-test-page-066-benthic-biodiversity-2" + ], + [ + "module_item", + "load-test-module-04|load-test-page-035", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "page", + "load-test-page-026", + "https://byu.instructure.com/courses/20736/pages/load-test-page-026-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-07|load-test-page-065", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "page", + "load-test-page-036", + "https://byu.instructure.com/courses/20736/pages/load-test-page-036-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-08|load-test-page-075", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-06|load-test-page-055", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-05|load-test-page-045", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "module_item", + "load-test-module-03|load-test-page-025", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "page", + "load-test-page-146", + "https://byu.instructure.com/courses/20736/pages/load-test-page-146-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-02|load-test-page-015", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "page", + "load-test-page-016", + "https://byu.instructure.com/courses/20736/pages/load-test-page-016-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-01|load-test-page-005", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "page", + "load-test-page-197", + "https://byu.instructure.com/courses/20736/pages/load-test-page-197-predator-prey-balance-2" + ], + [ + "page", + "load-test-page-006", + "https://byu.instructure.com/courses/20736/pages/load-test-page-006-nutrient-cycling-2" + ], + [ + "page", + "load-test-page-177", + "https://byu.instructure.com/courses/20736/pages/load-test-page-177-predator-prey-balance-2" + ], + [ + "module_item", + "load-test-module-20|load-test-page-196", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-19|load-test-page-186", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "page", + "load-test-page-187", + "https://byu.instructure.com/courses/20736/pages/load-test-page-187-current-driven-dispersal-2" + ], + [ + "module_item", + "load-test-module-18|load-test-page-176", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "page", + "load-test-page-167", + "https://byu.instructure.com/courses/20736/pages/load-test-page-167-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-16|load-test-page-156", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "page", + "load-test-page-157", + "https://byu.instructure.com/courses/20736/pages/load-test-page-157-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-17|load-test-page-166", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "page", + "load-test-page-147", + "https://byu.instructure.com/courses/20736/pages/load-test-page-147-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-137", + "https://byu.instructure.com/courses/20736/pages/load-test-page-137-current-driven-dispersal-2" + ], + [ + "module_item", + "load-test-module-15|load-test-page-146", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "page", + "load-test-page-127", + "https://byu.instructure.com/courses/20736/pages/load-test-page-127-current-driven-dispersal-2" + ], + [ + "module_item", + "load-test-module-14|load-test-page-136", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "page", + "load-test-page-117", + "https://byu.instructure.com/courses/20736/pages/load-test-page-117-deep-sea-adaptation-2" + ], + [ + "page", + "load-test-page-107", + "https://byu.instructure.com/courses/20736/pages/load-test-page-107-benthic-biodiversity-2" + ], + [ + "module_item", + "load-test-module-13|load-test-page-126", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "page", + "load-test-page-097", + "https://byu.instructure.com/courses/20736/pages/load-test-page-097-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-11|load-test-page-106", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-10|load-test-page-096", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "page", + "load-test-page-077", + "https://byu.instructure.com/courses/20736/pages/load-test-page-077-marine-conservation-policy-2" + ], + [ + "module_item", + "load-test-module-09|load-test-page-086", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "page", + "load-test-page-087", + "https://byu.instructure.com/courses/20736/pages/load-test-page-087-current-driven-dispersal-2" + ], + [ + "page", + "load-test-page-067", + "https://byu.instructure.com/courses/20736/pages/load-test-page-067-marine-conservation-policy-2" + ], + [ + "module_item", + "load-test-module-08|load-test-page-076", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "page", + "load-test-page-057", + "https://byu.instructure.com/courses/20736/pages/load-test-page-057-benthic-biodiversity-2" + ], + [ + "module_item", + "load-test-module-06|load-test-page-056", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-12|load-test-page-116", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "page", + "load-test-page-047", + "https://byu.instructure.com/courses/20736/pages/load-test-page-047-benthic-biodiversity-2" + ], + [ + "module_item", + "load-test-module-05|load-test-page-046", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "module_item", + "load-test-module-07|load-test-page-066", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "page", + "load-test-page-037", + "https://byu.instructure.com/courses/20736/pages/load-test-page-037-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-04|load-test-page-036", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "page", + "load-test-page-027", + "https://byu.instructure.com/courses/20736/pages/load-test-page-027-current-driven-dispersal-2" + ], + [ + "page", + "load-test-page-017", + "https://byu.instructure.com/courses/20736/pages/load-test-page-017-marine-conservation-policy-2" + ], + [ + "module_item", + "load-test-module-03|load-test-page-026", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "page", + "load-test-page-007", + "https://byu.instructure.com/courses/20736/pages/load-test-page-007-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-178", + "https://byu.instructure.com/courses/20736/pages/load-test-page-178-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-01|load-test-page-006", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "load-test-module-02|load-test-page-016", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "page", + "load-test-page-198", + "https://byu.instructure.com/courses/20736/pages/load-test-page-198-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-20|load-test-page-197", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "page", + "load-test-page-188", + "https://byu.instructure.com/courses/20736/pages/load-test-page-188-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-19|load-test-page-187", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "module_item", + "load-test-module-18|load-test-page-177", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "page", + "load-test-page-168", + "https://byu.instructure.com/courses/20736/pages/load-test-page-168-ocean-chemistry-2" + ], + [ + "module_item", + "load-test-module-17|load-test-page-167", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "page", + "load-test-page-158", + "https://byu.instructure.com/courses/20736/pages/load-test-page-158-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-16|load-test-page-157", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "module_item", + "load-test-module-15|load-test-page-147", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "page", + "load-test-page-148", + "https://byu.instructure.com/courses/20736/pages/load-test-page-148-nutrient-cycling-2" + ], + [ + "page", + "load-test-page-138", + "https://byu.instructure.com/courses/20736/pages/load-test-page-138-deep-sea-adaptation-2" + ], + [ + "page", + "load-test-page-128", + "https://byu.instructure.com/courses/20736/pages/load-test-page-128-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-14|load-test-page-137", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-13|load-test-page-127", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "page", + "load-test-page-118", + "https://byu.instructure.com/courses/20736/pages/load-test-page-118-benthic-biodiversity-2" + ], + [ + "page", + "load-test-page-108", + "https://byu.instructure.com/courses/20736/pages/load-test-page-108-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-11|load-test-page-107", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-12|load-test-page-117", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "page", + "load-test-page-098", + "https://byu.instructure.com/courses/20736/pages/load-test-page-098-food-web-transfer-2" + ], + [ + "page", + "load-test-page-088", + "https://byu.instructure.com/courses/20736/pages/load-test-page-088-deep-sea-adaptation-2" + ], + [ + "page", + "load-test-page-078", + "https://byu.instructure.com/courses/20736/pages/load-test-page-078-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-10|load-test-page-097", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "page", + "load-test-page-068", + "https://byu.instructure.com/courses/20736/pages/load-test-page-068-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-09|load-test-page-087", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "page", + "load-test-page-058", + "https://byu.instructure.com/courses/20736/pages/load-test-page-058-benthic-biodiversity-2" + ], + [ + "module_item", + "load-test-module-07|load-test-page-067", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-08|load-test-page-077", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "page", + "load-test-page-048", + "https://byu.instructure.com/courses/20736/pages/load-test-page-048-migration-behavior-2" + ], + [ + "page", + "load-test-page-038", + "https://byu.instructure.com/courses/20736/pages/load-test-page-038-ocean-chemistry-2" + ], + [ + "module_item", + "load-test-module-06|load-test-page-057", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "page", + "load-test-page-028", + "https://byu.instructure.com/courses/20736/pages/load-test-page-028-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-04|load-test-page-037", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "page", + "load-test-page-018", + "https://byu.instructure.com/courses/20736/pages/load-test-page-018-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-03|load-test-page-027", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "module_item", + "load-test-module-05|load-test-page-047", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "module_item", + "load-test-module-02|load-test-page-017", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-01|load-test-page-007", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "page", + "load-test-page-008", + "https://byu.instructure.com/courses/20736/pages/load-test-page-008-food-web-transfer-2" + ], + [ + "page", + "load-test-page-199", + "https://byu.instructure.com/courses/20736/pages/load-test-page-199-plankton-dynamics-2" + ], + [ + "page", + "load-test-page-189", + "https://byu.instructure.com/courses/20736/pages/load-test-page-189-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-20|load-test-page-198", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "page", + "load-test-page-169", + "https://byu.instructure.com/courses/20736/pages/load-test-page-169-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-179", + "https://byu.instructure.com/courses/20736/pages/load-test-page-179-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-19|load-test-page-188", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "module_item", + "load-test-module-17|load-test-page-168", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "page", + "load-test-page-159", + "https://byu.instructure.com/courses/20736/pages/load-test-page-159-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-149", + "https://byu.instructure.com/courses/20736/pages/load-test-page-149-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-16|load-test-page-158", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "module_item", + "load-test-module-18|load-test-page-178", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "page", + "load-test-page-139", + "https://byu.instructure.com/courses/20736/pages/load-test-page-139-nutrient-cycling-2" + ], + [ + "page", + "load-test-page-129", + "https://byu.instructure.com/courses/20736/pages/load-test-page-129-benthic-biodiversity-2" + ], + [ + "page", + "load-test-page-119", + "https://byu.instructure.com/courses/20736/pages/load-test-page-119-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-14|load-test-page-138", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-13|load-test-page-128", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "page", + "load-test-page-109", + "https://byu.instructure.com/courses/20736/pages/load-test-page-109-reef-restoration-2" + ], + [ + "module_item", + "load-test-module-15|load-test-page-148", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-12|load-test-page-118", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "page", + "load-test-page-099", + "https://byu.instructure.com/courses/20736/pages/load-test-page-099-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-11|load-test-page-108", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-10|load-test-page-098", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "page", + "load-test-page-089", + "https://byu.instructure.com/courses/20736/pages/load-test-page-089-predator-prey-balance-2" + ], + [ + "page", + "load-test-page-079", + "https://byu.instructure.com/courses/20736/pages/load-test-page-079-migration-behavior-2" + ], + [ + "page", + "load-test-page-059", + "https://byu.instructure.com/courses/20736/pages/load-test-page-059-predator-prey-balance-2" + ], + [ + "module_item", + "load-test-module-09|load-test-page-088", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "module_item", + "load-test-module-08|load-test-page-078", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "page", + "load-test-page-069", + "https://byu.instructure.com/courses/20736/pages/load-test-page-069-marine-conservation-policy-2" + ], + [ + "page", + "load-test-page-049", + "https://byu.instructure.com/courses/20736/pages/load-test-page-049-plankton-dynamics-2" + ], + [ + "module_item", + "load-test-module-06|load-test-page-058", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-07|load-test-page-068", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "page", + "load-test-page-039", + "https://byu.instructure.com/courses/20736/pages/load-test-page-039-current-driven-dispersal-2" + ], + [ + "module_item", + "load-test-module-05|load-test-page-048", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "page", + "load-test-page-029", + "https://byu.instructure.com/courses/20736/pages/load-test-page-029-deep-sea-adaptation-2" + ], + [ + "module_item", + "load-test-module-04|load-test-page-038", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "page", + "load-test-page-019", + "https://byu.instructure.com/courses/20736/pages/load-test-page-019-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-03|load-test-page-028", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "page", + "load-test-page-009", + "https://byu.instructure.com/courses/20736/pages/load-test-page-009-nutrient-cycling-2" + ], + [ + "page", + "load-test-page-200", + "https://byu.instructure.com/courses/20736/pages/load-test-page-200-ocean-chemistry-2" + ], + [ + "page", + "load-test-page-190", + "https://byu.instructure.com/courses/20736/pages/load-test-page-190-coastal-resilience-2" + ], + [ + "module_item", + "load-test-module-01|load-test-page-008", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "load-test-module-02|load-test-page-018", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-20|load-test-page-199", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "page", + "load-test-page-180", + "https://byu.instructure.com/courses/20736/pages/load-test-page-180-coastal-resilience-2" + ], + [ + "page", + "load-test-page-170", + "https://byu.instructure.com/courses/20736/pages/load-test-page-170-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-19|load-test-page-189", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "page", + "load-test-page-160", + "https://byu.instructure.com/courses/20736/pages/load-test-page-160-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-17|load-test-page-169", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-18|load-test-page-179", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "page", + "load-test-page-150", + "https://byu.instructure.com/courses/20736/pages/load-test-page-150-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-16|load-test-page-159", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "page", + "load-test-page-140", + "https://byu.instructure.com/courses/20736/pages/load-test-page-140-marine-conservation-policy-2" + ], + [ + "module_item", + "load-test-module-15|load-test-page-149", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-14|load-test-page-139", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "page", + "load-test-page-130", + "https://byu.instructure.com/courses/20736/pages/load-test-page-130-predator-prey-balance-2" + ], + [ + "module_item", + "load-test-module-13|load-test-page-129", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "page", + "load-test-page-120", + "https://byu.instructure.com/courses/20736/pages/load-test-page-120-marine-conservation-policy-2" + ], + [ + "page", + "load-test-page-110", + "https://byu.instructure.com/courses/20736/pages/load-test-page-110-migration-behavior-2" + ], + [ + "module_item", + "load-test-module-11|load-test-page-109", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "page", + "load-test-page-100", + "https://byu.instructure.com/courses/20736/pages/load-test-page-100-deep-sea-adaptation-2" + ], + [ + "page", + "load-test-page-080", + "https://byu.instructure.com/courses/20736/pages/load-test-page-080-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-12|load-test-page-119", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "page", + "load-test-page-090", + "https://byu.instructure.com/courses/20736/pages/load-test-page-090-food-web-transfer-2" + ], + [ + "module_item", + "load-test-module-10|load-test-page-099", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-09|load-test-page-089", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "page", + "load-test-page-070", + "https://byu.instructure.com/courses/20736/pages/load-test-page-070-benthic-biodiversity-2" + ], + [ + "module_item", + "load-test-module-08|load-test-page-079", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-07|load-test-page-069", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "page", + "load-test-page-060", + "https://byu.instructure.com/courses/20736/pages/load-test-page-060-nutrient-cycling-2" + ], + [ + "module_item", + "load-test-module-06|load-test-page-059", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "page", + "load-test-page-050", + "https://byu.instructure.com/courses/20736/pages/load-test-page-050-marine-conservation-policy-2" + ], + [ + "module_item", + "load-test-module-05|load-test-page-049", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "page", + "load-test-page-030", + "https://byu.instructure.com/courses/20736/pages/load-test-page-030-coastal-resilience-2" + ], + [ + "module_item", + "load-test-module-04|load-test-page-039", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "page", + "load-test-page-040", + "https://byu.instructure.com/courses/20736/pages/load-test-page-040-food-web-transfer-2" + ], + [ + "page", + "load-test-page-020", + "https://byu.instructure.com/courses/20736/pages/load-test-page-020-coastal-resilience-2" + ], + [ + "module_item", + "load-test-module-02|load-test-page-019", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "page", + "load-test-page-010", + "https://byu.instructure.com/courses/20736/pages/load-test-page-010-predator-prey-balance-2" + ], + [ + "module_item", + "load-test-module-03|load-test-page-029", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "module_item", + "load-test-module-01|load-test-page-009", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "load-test-module-19|load-test-page-190", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "module_item", + "load-test-module-20|load-test-page-200", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-15|load-test-page-150", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-18|load-test-page-180", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-16|load-test-page-160", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "module_item", + "load-test-module-14|load-test-page-140", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-17|load-test-page-170", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-13|load-test-page-130", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "module_item", + "load-test-module-12|load-test-page-120", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "module_item", + "load-test-module-11|load-test-page-110", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-09|load-test-page-090", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "module_item", + "load-test-module-10|load-test-page-100", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-07|load-test-page-070", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-08|load-test-page-080", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-06|load-test-page-060", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-04|load-test-page-040", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "module_item", + "load-test-module-05|load-test-page-050", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "module_item", + "load-test-module-03|load-test-page-030", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "module_item", + "load-test-module-01|load-test-page-010", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "load-test-module-02|load-test-page-020", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-20|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-18|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-19|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "module_item", + "load-test-module-17|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-16|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "module_item", + "load-test-module-14|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-15|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "assignment", + "load-test-assignment-171", + "https://byu.instructure.com/courses/20736/assignments/1335191" + ], + [ + "assignment", + "load-test-assignment-151", + "https://byu.instructure.com/courses/20736/assignments/1335193" + ], + [ + "assignment", + "load-test-assignment-181", + "https://byu.instructure.com/courses/20736/assignments/1335192" + ], + [ + "module_item", + "load-test-module-13|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "assignment", + "load-test-assignment-161", + "https://byu.instructure.com/courses/20736/assignments/1335196" + ], + [ + "assignment", + "load-test-assignment-191", + "https://byu.instructure.com/courses/20736/assignments/1335194" + ], + [ + "module_item", + "load-test-module-12|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "assignment", + "load-test-assignment-141", + "https://byu.instructure.com/courses/20736/assignments/1335195" + ], + [ + "assignment", + "load-test-assignment-121", + "https://byu.instructure.com/courses/20736/assignments/1335198" + ], + [ + "module_item", + "load-test-module-11|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-09|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "assignment", + "load-test-assignment-131", + "https://byu.instructure.com/courses/20736/assignments/1335197" + ], + [ + "module_item", + "load-test-module-10|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "assignment", + "load-test-assignment-111", + "https://byu.instructure.com/courses/20736/assignments/1335199" + ], + [ + "assignment", + "load-test-assignment-081", + "https://byu.instructure.com/courses/20736/assignments/1335200" + ], + [ + "module_item", + "load-test-module-08|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-07|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-06|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "assignment", + "load-test-assignment-101", + "https://byu.instructure.com/courses/20736/assignments/1335201" + ], + [ + "assignment", + "load-test-assignment-091", + "https://byu.instructure.com/courses/20736/assignments/1335202" + ], + [ + "module_item", + "load-test-module-05|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "module_item", + "load-test-module-04|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "assignment", + "load-test-assignment-061", + "https://byu.instructure.com/courses/20736/assignments/1335204" + ], + [ + "module_item", + "load-test-module-03|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "assignment", + "load-test-assignment-071", + "https://byu.instructure.com/courses/20736/assignments/1335203" + ], + [ + "assignment", + "load-test-assignment-051", + "https://byu.instructure.com/courses/20736/assignments/1335205" + ], + [ + "module_item", + "load-test-module-02|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "assignment", + "load-test-assignment-041", + "https://byu.instructure.com/courses/20736/assignments/1335206" + ], + [ + "module_item", + "load-test-module-01|Synthetic Assignments", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "assignment", + "load-test-assignment-031", + "https://byu.instructure.com/courses/20736/assignments/1335207" + ], + [ + "module_item", + "unit-3|Week 1 Wrap-Up", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "assignment", + "load-test-assignment-011", + "https://byu.instructure.com/courses/20736/assignments/1335208" + ], + [ + "assignment", + "load-test-assignment-021", + "https://byu.instructure.com/courses/20736/assignments/1335209" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-191", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "assignment", + "load-test-assignment-001", + "https://byu.instructure.com/courses/20736/assignments/1335210" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-181", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-171", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-161", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "assignment", + "lab-notebook-week1", + "https://byu.instructure.com/courses/20736/assignments/1335211" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-151", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "assignment", + "load-test-assignment-182", + "https://byu.instructure.com/courses/20736/assignments/1335213" + ], + [ + "assignment", + "load-test-assignment-192", + "https://byu.instructure.com/courses/20736/assignments/1335212" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-131", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "assignment", + "load-test-assignment-162", + "https://byu.instructure.com/courses/20736/assignments/1335215" + ], + [ + "assignment", + "load-test-assignment-172", + "https://byu.instructure.com/courses/20736/assignments/1335214" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-141", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "assignment", + "load-test-assignment-142", + "https://byu.instructure.com/courses/20736/assignments/1335216" + ], + [ + "assignment", + "load-test-assignment-152", + "https://byu.instructure.com/courses/20736/assignments/1335217" + ], + [ + "assignment", + "load-test-assignment-132", + "https://byu.instructure.com/courses/20736/assignments/1335218" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-121", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "assignment", + "load-test-assignment-112", + "https://byu.instructure.com/courses/20736/assignments/1335219" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-111", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-091", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-101", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "assignment", + "load-test-assignment-122", + "https://byu.instructure.com/courses/20736/assignments/1335220" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-081", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "assignment", + "load-test-assignment-102", + "https://byu.instructure.com/courses/20736/assignments/1335221" + ], + [ + "assignment", + "load-test-assignment-092", + "https://byu.instructure.com/courses/20736/assignments/1335223" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-071", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-061", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "assignment", + "load-test-assignment-082", + "https://byu.instructure.com/courses/20736/assignments/1335222" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-051", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-041", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "assignment", + "load-test-assignment-072", + "https://byu.instructure.com/courses/20736/assignments/1335224" + ], + [ + "assignment", + "load-test-assignment-062", + "https://byu.instructure.com/courses/20736/assignments/1335225" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-031", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "assignment", + "load-test-assignment-052", + "https://byu.instructure.com/courses/20736/assignments/1335226" + ], + [ + "assignment", + "load-test-assignment-042", + "https://byu.instructure.com/courses/20736/assignments/1335227" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-011", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-021", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "assignment", + "load-test-assignment-032", + "https://byu.instructure.com/courses/20736/assignments/1335228" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-001", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "unit-4|day-9-header", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "assignment", + "load-test-assignment-022", + "https://byu.instructure.com/courses/20736/assignments/1335229" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-192", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "unit-3|lab-notebook-week1", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "assignment", + "load-test-assignment-012", + "https://byu.instructure.com/courses/20736/assignments/1335230" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-182", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-172", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "assignment", + "load-test-assignment-193", + "https://byu.instructure.com/courses/20736/assignments/1335231" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-162", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "assignment", + "load-test-assignment-183", + "https://byu.instructure.com/courses/20736/assignments/1335232" + ], + [ + "assignment", + "load-test-assignment-173", + "https://byu.instructure.com/courses/20736/assignments/1335233" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-142", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-132", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-152", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-122", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "quiz", + "day-9-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610837" + ], + [ + "assignment", + "load-test-assignment-163", + "https://byu.instructure.com/courses/20736/assignments/1335236" + ], + [ + "assignment", + "load-test-assignment-153", + "https://byu.instructure.com/courses/20736/assignments/1335237" + ], + [ + "assignment", + "load-test-assignment-002", + "https://byu.instructure.com/courses/20736/assignments/1335234" + ], + [ + "assignment", + "load-test-assignment-143", + "https://byu.instructure.com/courses/20736/assignments/1335238" + ], + [ + "assignment", + "load-test-assignment-123", + "https://byu.instructure.com/courses/20736/assignments/1335240" + ], + [ + "assignment", + "load-test-assignment-133", + "https://byu.instructure.com/courses/20736/assignments/1335239" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-112", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-082", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-092", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "assignment", + "load-test-assignment-113", + "https://byu.instructure.com/courses/20736/assignments/1335241" + ], + [ + "assignment", + "load-test-assignment-103", + "https://byu.instructure.com/courses/20736/assignments/1335242" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-102", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-062", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-072", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "assignment", + "load-test-assignment-093", + "https://byu.instructure.com/courses/20736/assignments/1335243" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-052", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "assignment", + "load-test-assignment-083", + "https://byu.instructure.com/courses/20736/assignments/1335244" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-042", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "assignment", + "load-test-assignment-063", + "https://byu.instructure.com/courses/20736/assignments/1335245" + ], + [ + "assignment", + "load-test-assignment-053", + "https://byu.instructure.com/courses/20736/assignments/1335247" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-032", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "assignment", + "load-test-assignment-073", + "https://byu.instructure.com/courses/20736/assignments/1335246" + ], + [ + "assignment", + "load-test-assignment-043", + "https://byu.instructure.com/courses/20736/assignments/1335248" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-022", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-012", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "assignment", + "load-test-assignment-033", + "https://byu.instructure.com/courses/20736/assignments/1335249" + ], + [ + "page", + "day-9-marine-mammals", + "https://byu.instructure.com/courses/20736/pages/day-9-marine-mammals-12" + ], + [ + "assignment", + "load-test-assignment-013", + "https://byu.instructure.com/courses/20736/assignments/1335251" + ], + [ + "assignment", + "load-test-assignment-023", + "https://byu.instructure.com/courses/20736/assignments/1335250" + ], + [ + "module_item", + "unit-3|day-6-header", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-002", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "unit-4|day-9-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "assignment", + "load-test-assignment-003", + "https://byu.instructure.com/courses/20736/assignments/1335252" + ], + [ + "module_item", + "unit-1|day-1-header", + "https://byu.instructure.com/courses/20736#module_382502" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-193", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-173", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-183", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-153", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "quiz", + "day-6-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610838" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-163", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "assignment", + "load-test-assignment-194", + "https://byu.instructure.com/courses/20736/assignments/1335255" + ], + [ + "assignment", + "load-test-assignment-184", + "https://byu.instructure.com/courses/20736/assignments/1335256" + ], + [ + "assignment", + "load-test-assignment-174", + "https://byu.instructure.com/courses/20736/assignments/1335257" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-143", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "assignment", + "load-test-assignment-164", + "https://byu.instructure.com/courses/20736/assignments/1335258" + ], + [ + "quiz", + "day-1-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610839" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-133", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "assignment", + "load-test-assignment-154", + "https://byu.instructure.com/courses/20736/assignments/1335259" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-123", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-113", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "assignment", + "load-test-assignment-134", + "https://byu.instructure.com/courses/20736/assignments/1335261" + ], + [ + "assignment", + "load-test-assignment-144", + "https://byu.instructure.com/courses/20736/assignments/1335260" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-103", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "assignment", + "load-test-assignment-124", + "https://byu.instructure.com/courses/20736/assignments/1335262" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-093", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-073", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-083", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "assignment", + "load-test-assignment-094", + "https://byu.instructure.com/courses/20736/assignments/1335264" + ], + [ + "assignment", + "load-test-assignment-084", + "https://byu.instructure.com/courses/20736/assignments/1335265" + ], + [ + "assignment", + "load-test-assignment-114", + "https://byu.instructure.com/courses/20736/assignments/1335263" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-063", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "assignment", + "load-test-assignment-074", + "https://byu.instructure.com/courses/20736/assignments/1335267" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-043", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "assignment", + "load-test-assignment-064", + "https://byu.instructure.com/courses/20736/assignments/1335268" + ], + [ + "assignment", + "load-test-assignment-104", + "https://byu.instructure.com/courses/20736/assignments/1335266" + ], + [ + "assignment", + "load-test-assignment-054", + "https://byu.instructure.com/courses/20736/assignments/1335269" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-053", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-023", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "assignment", + "load-test-assignment-044", + "https://byu.instructure.com/courses/20736/assignments/1335270" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-033", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "assignment", + "load-test-assignment-024", + "https://byu.instructure.com/courses/20736/assignments/1335271" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-013", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "assignment", + "load-test-assignment-014", + "https://byu.instructure.com/courses/20736/assignments/1335272" + ], + [ + "assignment", + "load-test-assignment-034", + "https://byu.instructure.com/courses/20736/assignments/1335273" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-003", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "page", + "day-6-coral-reefs", + "https://byu.instructure.com/courses/20736/pages/day-6-coral-reefs-12" + ], + [ + "page", + "day-1-welcome", + "https://byu.instructure.com/courses/20736/pages/day-1-welcome-to-the-sea-12" + ], + [ + "module_item", + "unit-3|day-6-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "module_item", + "unit-4|day-9-marine-mammals", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "module_item", + "unit-1|day-1-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382502" + ], + [ + "assignment", + "load-test-assignment-004", + "https://byu.instructure.com/courses/20736/assignments/1335274" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-194", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-174", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-184", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-164", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "assignment", + "load-test-assignment-195", + "https://byu.instructure.com/courses/20736/assignments/1335275" + ], + [ + "assignment", + "group-presentation", + "https://byu.instructure.com/courses/20736/assignments/1335276" + ], + [ + "assignment", + "load-test-assignment-175", + "https://byu.instructure.com/courses/20736/assignments/1335277" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-154", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "assignment", + "load-test-assignment-185", + "https://byu.instructure.com/courses/20736/assignments/1335278" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-144", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-134", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-124", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "assignment", + "load-test-assignment-165", + "https://byu.instructure.com/courses/20736/assignments/1335279" + ], + [ + "assignment", + "load-test-assignment-145", + "https://byu.instructure.com/courses/20736/assignments/1335280" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-114", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "assignment", + "load-test-assignment-155", + "https://byu.instructure.com/courses/20736/assignments/1335281" + ], + [ + "assignment", + "load-test-assignment-135", + "https://byu.instructure.com/courses/20736/assignments/1335282" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-104", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "assignment", + "load-test-assignment-125", + "https://byu.instructure.com/courses/20736/assignments/1335283" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-084", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-094", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "assignment", + "load-test-assignment-115", + "https://byu.instructure.com/courses/20736/assignments/1335284" + ], + [ + "assignment", + "load-test-assignment-105", + "https://byu.instructure.com/courses/20736/assignments/1335285" + ], + [ + "assignment", + "load-test-assignment-085", + "https://byu.instructure.com/courses/20736/assignments/1335286" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-064", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-074", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-054", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "assignment", + "load-test-assignment-095", + "https://byu.instructure.com/courses/20736/assignments/1335287" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-044", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "assignment", + "load-test-assignment-075", + "https://byu.instructure.com/courses/20736/assignments/1335288" + ], + [ + "assignment", + "load-test-assignment-065", + "https://byu.instructure.com/courses/20736/assignments/1335289" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-034", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-024", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "assignment", + "load-test-assignment-045", + "https://byu.instructure.com/courses/20736/assignments/1335290" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-014", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-004", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "assignment", + "load-test-assignment-035", + "https://byu.instructure.com/courses/20736/assignments/1335292" + ], + [ + "assignment", + "load-test-assignment-055", + "https://byu.instructure.com/courses/20736/assignments/1335291" + ], + [ + "assignment", + "load-test-assignment-025", + "https://byu.instructure.com/courses/20736/assignments/1335293" + ], + [ + "module_item", + "unit-4|group-presentation", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "module_item", + "unit-2|day-4-header", + "https://byu.instructure.com/courses/20736#module_382503" + ], + [ + "assignment", + "load-test-assignment-015", + "https://byu.instructure.com/courses/20736/assignments/1335295" + ], + [ + "module_item", + "unit-3|day-6-coral-reefs", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "assignment", + "load-test-assignment-005", + "https://byu.instructure.com/courses/20736/assignments/1335294" + ], + [ + "module_item", + "unit-1|day-1-welcome", + "https://byu.instructure.com/courses/20736#module_382502" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-195", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-175", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-185", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "quiz", + "day-4-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610840" + ], + [ + "assignment", + "load-test-assignment-196", + "https://byu.instructure.com/courses/20736/assignments/1335297" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-165", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-155", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-145", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-135", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "assignment", + "load-test-assignment-186", + "https://byu.instructure.com/courses/20736/assignments/1335298" + ], + [ + "assignment", + "load-test-assignment-156", + "https://byu.instructure.com/courses/20736/assignments/1335301" + ], + [ + "assignment", + "load-test-assignment-176", + "https://byu.instructure.com/courses/20736/assignments/1335299" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-125", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "assignment", + "load-test-assignment-166", + "https://byu.instructure.com/courses/20736/assignments/1335300" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-115", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "assignment", + "load-test-assignment-146", + "https://byu.instructure.com/courses/20736/assignments/1335302" + ], + [ + "assignment", + "load-test-assignment-126", + "https://byu.instructure.com/courses/20736/assignments/1335303" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-105", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "assignment", + "load-test-assignment-136", + "https://byu.instructure.com/courses/20736/assignments/1335304" + ], + [ + "assignment", + "load-test-assignment-116", + "https://byu.instructure.com/courses/20736/assignments/1335305" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-095", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-075", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-085", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "assignment", + "load-test-assignment-106", + "https://byu.instructure.com/courses/20736/assignments/1335306" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-065", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "assignment", + "load-test-assignment-096", + "https://byu.instructure.com/courses/20736/assignments/1335307" + ], + [ + "assignment", + "load-test-assignment-076", + "https://byu.instructure.com/courses/20736/assignments/1335308" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-055", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-045", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "assignment", + "load-test-assignment-066", + "https://byu.instructure.com/courses/20736/assignments/1335309" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-035", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "assignment", + "load-test-assignment-086", + "https://byu.instructure.com/courses/20736/assignments/1335310" + ], + [ + "assignment", + "load-test-assignment-056", + "https://byu.instructure.com/courses/20736/assignments/1335311" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-025", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "assignment", + "load-test-assignment-046", + "https://byu.instructure.com/courses/20736/assignments/1335312" + ], + [ + "assignment", + "load-test-assignment-026", + "https://byu.instructure.com/courses/20736/assignments/1335313" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-005", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "unit-4|day-10-header", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-015", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "assignment", + "load-test-assignment-016", + "https://byu.instructure.com/courses/20736/assignments/1335314" + ], + [ + "module_item", + "unit-3|day-7-header", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "page", + "day-4-phytoplankton", + "https://byu.instructure.com/courses/20736/pages/day-4-phytoplankton-and-primary-production-12" + ], + [ + "module_item", + "unit-2|day-4-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382503" + ], + [ + "assignment", + "load-test-assignment-006", + "https://byu.instructure.com/courses/20736/assignments/1335316" + ], + [ + "module_item", + "unit-1|day-2-header", + "https://byu.instructure.com/courses/20736#module_382502" + ], + [ + "assignment", + "load-test-assignment-036", + "https://byu.instructure.com/courses/20736/assignments/1335315" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-196", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-186", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "assignment", + "load-test-assignment-197", + "https://byu.instructure.com/courses/20736/assignments/1335320" + ], + [ + "quiz", + "day-10-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610841" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-176", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "assignment", + "load-test-assignment-187", + "https://byu.instructure.com/courses/20736/assignments/1335321" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-166", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "quiz", + "day-7-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610842" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-156", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "assignment", + "load-test-assignment-177", + "https://byu.instructure.com/courses/20736/assignments/1335322" + ], + [ + "assignment", + "load-test-assignment-167", + "https://byu.instructure.com/courses/20736/assignments/1335323" + ], + [ + "assignment", + "load-test-assignment-157", + "https://byu.instructure.com/courses/20736/assignments/1335324" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-146", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-136", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "assignment", + "load-test-assignment-147", + "https://byu.instructure.com/courses/20736/assignments/1335325" + ], + [ + "quiz", + "day-2-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-126", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-116", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-106", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-086", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-096", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "assignment", + "load-test-assignment-127", + "https://byu.instructure.com/courses/20736/assignments/1335328" + ], + [ + "assignment", + "load-test-assignment-137", + "https://byu.instructure.com/courses/20736/assignments/1335326" + ], + [ + "assignment", + "load-test-assignment-117", + "https://byu.instructure.com/courses/20736/assignments/1335327" + ], + [ + "assignment", + "load-test-assignment-107", + "https://byu.instructure.com/courses/20736/assignments/1335329" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-076", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-066", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "assignment", + "load-test-assignment-097", + "https://byu.instructure.com/courses/20736/assignments/1335330" + ], + [ + "assignment", + "load-test-assignment-087", + "https://byu.instructure.com/courses/20736/assignments/1335331" + ], + [ + "assignment", + "load-test-assignment-077", + "https://byu.instructure.com/courses/20736/assignments/1335332" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-046", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-056", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "assignment", + "load-test-assignment-067", + "https://byu.instructure.com/courses/20736/assignments/1335333" + ], + [ + "assignment", + "load-test-assignment-047", + "https://byu.instructure.com/courses/20736/assignments/1335334" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-026", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "assignment", + "load-test-assignment-057", + "https://byu.instructure.com/courses/20736/assignments/1335335" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-036", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-016", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "assignment", + "load-test-assignment-027", + "https://byu.instructure.com/courses/20736/assignments/1335336" + ], + [ + "page", + "day-10-sharks-rays", + "https://byu.instructure.com/courses/20736/pages/day-10-sharks-rays-and-sea-turtles-12" + ], + [ + "assignment", + "load-test-assignment-037", + "https://byu.instructure.com/courses/20736/assignments/1335337" + ], + [ + "page", + "day-7-estuaries-mangroves", + "https://byu.instructure.com/courses/20736/pages/day-7-estuaries-and-mangroves-12" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-006", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "assignment", + "load-test-assignment-017", + "https://byu.instructure.com/courses/20736/assignments/1335338" + ], + [ + "page", + "day-2-ocean-geography", + "https://byu.instructure.com/courses/20736/pages/day-2-ocean-geography-12" + ], + [ + "module_item", + "unit-4|day-10-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "assignment", + "load-test-assignment-007", + "https://byu.instructure.com/courses/20736/assignments/1335339" + ], + [ + "module_item", + "unit-2|day-4-phytoplankton", + "https://byu.instructure.com/courses/20736#module_382503" + ], + [ + "module_item", + "unit-1|day-2-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382502" + ], + [ + "module_item", + "unit-3|day-7-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-197", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-177", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-187", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "assignment", + "load-test-assignment-198", + "https://byu.instructure.com/courses/20736/assignments/1335340" + ], + [ + "assignment", + "load-test-assignment-178", + "https://byu.instructure.com/courses/20736/assignments/1335341" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-167", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-157", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "assignment", + "load-test-assignment-168", + "https://byu.instructure.com/courses/20736/assignments/1335342" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-147", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-137", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-127", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "assignment", + "load-test-assignment-158", + "https://byu.instructure.com/courses/20736/assignments/1335343" + ], + [ + "assignment", + "load-test-assignment-138", + "https://byu.instructure.com/courses/20736/assignments/1335344" + ], + [ + "assignment", + "load-test-assignment-148", + "https://byu.instructure.com/courses/20736/assignments/1335345" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-117", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "assignment", + "load-test-assignment-128", + "https://byu.instructure.com/courses/20736/assignments/1335346" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-107", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "assignment", + "load-test-assignment-118", + "https://byu.instructure.com/courses/20736/assignments/1335347" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-087", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "assignment", + "load-test-assignment-108", + "https://byu.instructure.com/courses/20736/assignments/1335348" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-097", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "assignment", + "load-test-assignment-098", + "https://byu.instructure.com/courses/20736/assignments/1335349" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-077", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-067", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-057", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "assignment", + "load-test-assignment-088", + "https://byu.instructure.com/courses/20736/assignments/1335350" + ], + [ + "assignment", + "load-test-assignment-078", + "https://byu.instructure.com/courses/20736/assignments/1335351" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-037", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "assignment", + "load-test-assignment-188", + "https://byu.instructure.com/courses/20736/assignments/1335352" + ], + [ + "assignment", + "load-test-assignment-068", + "https://byu.instructure.com/courses/20736/assignments/1335353" + ], + [ + "assignment", + "load-test-assignment-058", + "https://byu.instructure.com/courses/20736/assignments/1335354" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-047", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-027", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "assignment", + "load-test-assignment-048", + "https://byu.instructure.com/courses/20736/assignments/1335355" + ], + [ + "assignment", + "load-test-assignment-038", + "https://byu.instructure.com/courses/20736/assignments/1335356" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-007", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-017", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "assignment", + "load-test-assignment-018", + "https://byu.instructure.com/courses/20736/assignments/1335357" + ], + [ + "module_item", + "unit-4|day-10-sharks-rays", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "module_item", + "unit-2|day-5-header", + "https://byu.instructure.com/courses/20736#module_382503" + ], + [ + "assignment", + "load-test-assignment-028", + "https://byu.instructure.com/courses/20736/assignments/1335359" + ], + [ + "assignment", + "load-test-assignment-008", + "https://byu.instructure.com/courses/20736/assignments/1335358" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-198", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "unit-1|day-2-ocean-geography", + "https://byu.instructure.com/courses/20736#module_382502" + ], + [ + "module_item", + "unit-3|day-7-estuaries-mangroves", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-188", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "assignment", + "load-test-assignment-199", + "https://byu.instructure.com/courses/20736/assignments/1335361" + ], + [ + "quiz", + "day-5-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-178", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-168", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "assignment", + "load-test-assignment-189", + "https://byu.instructure.com/courses/20736/assignments/1335362" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-158", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "assignment", + "load-test-assignment-179", + "https://byu.instructure.com/courses/20736/assignments/1335363" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-148", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "assignment", + "load-test-assignment-169", + "https://byu.instructure.com/courses/20736/assignments/1335364" + ], + [ + "assignment", + "load-test-assignment-159", + "https://byu.instructure.com/courses/20736/assignments/1335365" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-128", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-138", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "assignment", + "load-test-assignment-149", + "https://byu.instructure.com/courses/20736/assignments/1335366" + ], + [ + "assignment", + "load-test-assignment-139", + "https://byu.instructure.com/courses/20736/assignments/1335367" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-108", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "assignment", + "load-test-assignment-129", + "https://byu.instructure.com/courses/20736/assignments/1335368" + ], + [ + "assignment", + "load-test-assignment-119", + "https://byu.instructure.com/courses/20736/assignments/1335369" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-098", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-078", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-088", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "assignment", + "load-test-assignment-109", + "https://byu.instructure.com/courses/20736/assignments/1335370" + ], + [ + "assignment", + "load-test-assignment-099", + "https://byu.instructure.com/courses/20736/assignments/1335371" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-068", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-118", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "assignment", + "load-test-assignment-089", + "https://byu.instructure.com/courses/20736/assignments/1335372" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-058", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "assignment", + "load-test-assignment-079", + "https://byu.instructure.com/courses/20736/assignments/1335373" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-038", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-048", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "assignment", + "load-test-assignment-069", + "https://byu.instructure.com/courses/20736/assignments/1335374" + ], + [ + "assignment", + "load-test-assignment-049", + "https://byu.instructure.com/courses/20736/assignments/1335375" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-028", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "assignment", + "load-test-assignment-059", + "https://byu.instructure.com/courses/20736/assignments/1335376" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-018", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "assignment", + "load-test-assignment-039", + "https://byu.instructure.com/courses/20736/assignments/1335377" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-008", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "unit-4|Final Assessments", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "page", + "day-5-zooplankton", + "https://byu.instructure.com/courses/20736/pages/day-5-zooplankton-and-food-webs-12" + ], + [ + "assignment", + "load-test-assignment-029", + "https://byu.instructure.com/courses/20736/assignments/1335378" + ], + [ + "module_item", + "unit-3|day-8-header", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "assignment", + "load-test-assignment-019", + "https://byu.instructure.com/courses/20736/assignments/1335379" + ], + [ + "assignment", + "load-test-assignment-009", + "https://byu.instructure.com/courses/20736/assignments/1335380" + ], + [ + "assignment", + "lab-notebook-week2", + "https://byu.instructure.com/courses/20736/assignments/1335382" + ], + [ + "module_item", + "unit-1|day-3-header", + "https://byu.instructure.com/courses/20736#module_382502" + ], + [ + "module_item", + "unit-2|day-5-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382503" + ], + [ + "quiz", + "day-8-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610846" + ], + [ + "quiz", + "comprehensive-marine-biology-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-199", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-189", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "assignment", + "load-test-assignment-200", + "https://byu.instructure.com/courses/20736/assignments/1335385" + ], + [ + "quiz", + "day-3-prep-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610847" + ], + [ + "assignment", + "load-test-assignment-190", + "https://byu.instructure.com/courses/20736/assignments/1335386" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-179", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "quiz", + "midweek-quiz", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "assignment", + "load-test-assignment-180", + "https://byu.instructure.com/courses/20736/assignments/1335387" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-159", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "assignment", + "load-test-assignment-170", + "https://byu.instructure.com/courses/20736/assignments/1335388" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-169", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-149", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-139", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-129", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "assignment", + "load-test-assignment-160", + "https://byu.instructure.com/courses/20736/assignments/1335389" + ], + [ + "assignment", + "load-test-assignment-150", + "https://byu.instructure.com/courses/20736/assignments/1335390" + ], + [ + "assignment", + "load-test-assignment-140", + "https://byu.instructure.com/courses/20736/assignments/1335391" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-109", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-119", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "assignment", + "load-test-assignment-130", + "https://byu.instructure.com/courses/20736/assignments/1335392" + ], + [ + "assignment", + "load-test-assignment-120", + "https://byu.instructure.com/courses/20736/assignments/1335393" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-099", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-089", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-079", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "assignment", + "load-test-assignment-110", + "https://byu.instructure.com/courses/20736/assignments/1335394" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-069", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "assignment", + "load-test-assignment-100", + "https://byu.instructure.com/courses/20736/assignments/1335395" + ], + [ + "assignment", + "load-test-assignment-080", + "https://byu.instructure.com/courses/20736/assignments/1335397" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-059", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-049", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "assignment", + "load-test-assignment-070", + "https://byu.instructure.com/courses/20736/assignments/1335398" + ], + [ + "assignment", + "load-test-assignment-090", + "https://byu.instructure.com/courses/20736/assignments/1335396" + ], + [ + "assignment", + "load-test-assignment-060", + "https://byu.instructure.com/courses/20736/assignments/1335399" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-039", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-029", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "assignment", + "load-test-assignment-050", + "https://byu.instructure.com/courses/20736/assignments/1335400" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-019", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-009", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "assignment", + "load-test-assignment-040", + "https://byu.instructure.com/courses/20736/assignments/1335401" + ], + [ + "assignment", + "load-test-assignment-030", + "https://byu.instructure.com/courses/20736/assignments/1335402" + ], + [ + "module_item", + "comprehensive-review|Course-Spanning Practice", + "https://byu.instructure.com/courses/20736#module_382506" + ], + [ + "page", + "day-8-deep-sea", + "https://byu.instructure.com/courses/20736/pages/day-8-the-deep-sea-12" + ], + [ + "assignment", + "load-test-assignment-020", + "https://byu.instructure.com/courses/20736/assignments/1335403" + ], + [ + "module_item", + "unit-4|lab-notebook-week2", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "assignment", + "load-test-assignment-010", + "https://byu.instructure.com/courses/20736/assignments/1335404" + ], + [ + "module_item", + "unit-3|day-8-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "module_item", + "unit-2|day-5-zooplankton", + "https://byu.instructure.com/courses/20736#module_382503" + ], + [ + "page", + "day-3-seawater-chemistry", + "https://byu.instructure.com/courses/20736/pages/day-3-seawater-chemistry-12" + ], + [ + "module_item", + "unit-1|day-3-prep-quiz", + "https://byu.instructure.com/courses/20736#module_382502" + ], + [ + "assignment", + "final-reflection-essay", + "https://byu.instructure.com/courses/20736/assignments/1335405" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q196", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q189", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q197", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q192", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q193", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q199", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q195", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q188", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q190", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q191", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q194", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q198", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q187", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q186", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q184", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q181", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q183", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q180", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q179", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q185", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q176", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q182", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q177", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q178", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q175", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q174", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q170", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q172", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q171", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q168", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q173", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q167", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q169", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q165", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q166", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q164", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q163", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q162", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q161", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q158", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q156", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q160", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q159", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q157", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q155", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q154", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q153", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q151", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q147", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q146", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q145", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q152", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q150", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q144", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q149", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q148", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q142", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q141", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q143", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q136", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q140", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q139", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q138", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q132", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q137", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q134", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q135", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q133", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q131", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q130", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q126", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q125", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q122", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q128", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q129", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q127", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q123", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q124", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q120", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q121", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q119", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q116", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q118", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q117", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q115", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q110", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q108", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q113", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q112", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q114", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q107", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q109", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q111", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q105", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q103", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q106", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q104", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q101", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q102", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q99", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q100", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q94", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q98", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q93", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q95", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q92", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q96", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q97", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q91", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q90", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q87", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q89", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q88", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q83", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q80", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q86", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q79", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q78", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q84", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q85", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q82", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q81", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q75", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q77", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q74", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q76", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q67", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q73", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q69", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q72", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q71", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q70", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q66", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q68", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q65", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q62", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q64", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q60", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q63", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q57", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q56", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q55", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q58", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q61", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q59", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q53", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q54", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q50", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q48", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q49", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q47", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q52", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q51", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q43", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q44", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q42", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q46", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q45", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q40", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q38", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q36", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q41", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q33", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q39", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q31", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q37", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q35", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q30", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q34", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q32", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q29", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q27", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q26", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q28", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q24", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q25", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q19", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q21", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q20", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q18", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q23", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q22", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q17", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q14", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q13", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q16", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q15", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q12", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q10", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q11", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q7", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q9", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q8", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "midweek-quiz|q11", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "comprehensive-marine-biology-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "midweek-quiz|q12", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q13", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q14", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q10", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q9", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q7", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q8", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "day-10-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610841" + ], + [ + "quiz_question", + "midweek-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "midweek-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question", + "day-10-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610841" + ], + [ + "quiz_question", + "day-10-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610841" + ], + [ + "quiz_question", + "day-10-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610841" + ], + [ + "quiz_question", + "day-10-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610841" + ], + [ + "quiz_question", + "day-9-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610837" + ], + [ + "quiz_question", + "day-9-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610837" + ], + [ + "quiz_question", + "day-9-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610837" + ], + [ + "quiz_question", + "day-10-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610841" + ], + [ + "quiz_question", + "day-10-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610841" + ], + [ + "quiz_question", + "day-9-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610837" + ], + [ + "quiz_question", + "day-9-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610837" + ], + [ + "quiz_question", + "day-8-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610846" + ], + [ + "quiz_question", + "day-9-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610837" + ], + [ + "quiz_question", + "day-8-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610846" + ], + [ + "quiz_question", + "day-9-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610837" + ], + [ + "quiz_question", + "day-8-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610846" + ], + [ + "quiz_question", + "day-8-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610846" + ], + [ + "quiz_question", + "day-8-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610846" + ], + [ + "quiz_question", + "day-8-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610846" + ], + [ + "quiz_question", + "day-7-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610842" + ], + [ + "quiz_question", + "day-8-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610846" + ], + [ + "quiz_question", + "day-7-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610842" + ], + [ + "quiz_question", + "day-7-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610842" + ], + [ + "quiz_question", + "day-7-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610842" + ], + [ + "quiz_question", + "day-7-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610842" + ], + [ + "quiz_question", + "day-7-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610842" + ], + [ + "quiz_question", + "day-6-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610838" + ], + [ + "quiz_question", + "day-7-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610842" + ], + [ + "quiz_question", + "day-6-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610838" + ], + [ + "quiz_question", + "day-6-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610838" + ], + [ + "quiz_question", + "day-6-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610838" + ], + [ + "quiz_question", + "day-6-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610838" + ], + [ + "quiz_question", + "day-5-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ], + [ + "quiz_question", + "day-5-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ], + [ + "quiz_question", + "day-5-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ], + [ + "quiz_question", + "day-6-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610838" + ], + [ + "quiz_question", + "day-5-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ], + [ + "quiz_question", + "day-5-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ], + [ + "quiz_question", + "day-6-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610838" + ], + [ + "quiz_question", + "day-5-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ], + [ + "quiz_question", + "day-4-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610840" + ], + [ + "quiz_question", + "day-5-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ], + [ + "quiz_question", + "day-4-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610840" + ], + [ + "quiz_question", + "day-3-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610847" + ], + [ + "quiz_question", + "day-4-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610840" + ], + [ + "quiz_question", + "day-4-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610840" + ], + [ + "quiz_question", + "day-4-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610840" + ], + [ + "quiz_question", + "day-4-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610840" + ], + [ + "quiz_question", + "day-3-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610847" + ], + [ + "quiz_question", + "day-3-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610847" + ], + [ + "quiz_question", + "day-4-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610840" + ], + [ + "quiz_question", + "day-3-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610847" + ], + [ + "quiz_question", + "day-3-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610847" + ], + [ + "quiz_question", + "day-3-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610847" + ], + [ + "quiz_question", + "day-2-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ], + [ + "quiz_question", + "day-3-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610847" + ], + [ + "quiz_question", + "day-1-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610839" + ], + [ + "quiz_question", + "day-2-prep-quiz|q6", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ], + [ + "quiz_question", + "day-2-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ], + [ + "quiz_question", + "day-2-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ], + [ + "module_item", + "load-test-module-20|load-test-assignment-200", + "https://byu.instructure.com/courses/20736#module_382526" + ], + [ + "quiz_question", + "day-2-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ], + [ + "module_item", + "load-test-module-19|load-test-assignment-190", + "https://byu.instructure.com/courses/20736#module_382525" + ], + [ + "quiz_question", + "day-2-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ], + [ + "module_item", + "load-test-module-18|load-test-assignment-180", + "https://byu.instructure.com/courses/20736#module_382524" + ], + [ + "module_item", + "load-test-module-17|load-test-assignment-170", + "https://byu.instructure.com/courses/20736#module_382523" + ], + [ + "module_item", + "load-test-module-16|load-test-assignment-160", + "https://byu.instructure.com/courses/20736#module_382522" + ], + [ + "module_item", + "load-test-module-15|load-test-assignment-150", + "https://byu.instructure.com/courses/20736#module_382521" + ], + [ + "quiz_question", + "day-2-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ], + [ + "module_item", + "load-test-module-14|load-test-assignment-140", + "https://byu.instructure.com/courses/20736#module_382520" + ], + [ + "module_item", + "load-test-module-13|load-test-assignment-130", + "https://byu.instructure.com/courses/20736#module_382519" + ], + [ + "module_item", + "load-test-module-12|load-test-assignment-120", + "https://byu.instructure.com/courses/20736#module_382518" + ], + [ + "module_item", + "load-test-module-11|load-test-assignment-110", + "https://byu.instructure.com/courses/20736#module_382517" + ], + [ + "module_item", + "load-test-module-09|load-test-assignment-090", + "https://byu.instructure.com/courses/20736#module_382515" + ], + [ + "module_item", + "load-test-module-08|load-test-assignment-080", + "https://byu.instructure.com/courses/20736#module_382514" + ], + [ + "quiz_question", + "day-1-prep-quiz|q4", + "https://byu.instructure.com/courses/20736/quizzes/610839" + ], + [ + "module_item", + "load-test-module-10|load-test-assignment-100", + "https://byu.instructure.com/courses/20736#module_382516" + ], + [ + "module_item", + "load-test-module-07|load-test-assignment-070", + "https://byu.instructure.com/courses/20736#module_382513" + ], + [ + "module_item", + "load-test-module-06|load-test-assignment-060", + "https://byu.instructure.com/courses/20736#module_382512" + ], + [ + "module_item", + "load-test-module-05|load-test-assignment-050", + "https://byu.instructure.com/courses/20736#module_382511" + ], + [ + "quiz_question", + "day-1-prep-quiz|q3", + "https://byu.instructure.com/courses/20736/quizzes/610839" + ], + [ + "module_item", + "load-test-module-02|load-test-assignment-020", + "https://byu.instructure.com/courses/20736#module_382508" + ], + [ + "module_item", + "load-test-module-04|load-test-assignment-040", + "https://byu.instructure.com/courses/20736#module_382510" + ], + [ + "module_item", + "load-test-module-03|load-test-assignment-030", + "https://byu.instructure.com/courses/20736#module_382509" + ], + [ + "module_item", + "load-test-module-01|load-test-assignment-010", + "https://byu.instructure.com/courses/20736#module_382507" + ], + [ + "module_item", + "comprehensive-review|comprehensive-marine-biology-quiz", + "https://byu.instructure.com/courses/20736#module_382506" + ], + [ + "module_item", + "unit-4|final-reflection-essay", + "https://byu.instructure.com/courses/20736#module_382505" + ], + [ + "module_item", + "unit-3|day-8-deep-sea", + "https://byu.instructure.com/courses/20736#module_382504" + ], + [ + "module_item", + "unit-2|midweek-quiz", + "https://byu.instructure.com/courses/20736#module_382503" + ], + [ + "module_item", + "unit-1|day-3-seawater-chemistry", + "https://byu.instructure.com/courses/20736#module_382502" + ], + [ + "quiz_question_order", + "midweek-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610848" + ], + [ + "quiz_question_order", + "comprehensive-marine-biology-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610845" + ], + [ + "quiz_question", + "day-1-prep-quiz|q5", + "https://byu.instructure.com/courses/20736/quizzes/610839" + ], + [ + "quiz_question_order", + "day-10-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610841" + ], + [ + "quiz_question_order", + "day-8-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610846" + ], + [ + "quiz_question_order", + "day-9-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610837" + ], + [ + "quiz_question_order", + "day-6-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610838" + ], + [ + "quiz_question_order", + "day-7-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610842" + ], + [ + "quiz_question_order", + "day-5-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ], + [ + "quiz_question_order", + "day-4-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610840" + ], + [ + "quiz_question_order", + "day-3-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610847" + ], + [ + "quiz_question_order", + "day-2-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ], + [ + "quiz_question", + "day-1-prep-quiz|q2", + "https://byu.instructure.com/courses/20736/quizzes/610839" + ], + [ + "quiz_question", + "day-1-prep-quiz|q1", + "https://byu.instructure.com/courses/20736/quizzes/610839" + ], + [ + "quiz_question", + "day-1-prep-quiz|q0", + "https://byu.instructure.com/courses/20736/quizzes/610839" + ], + [ + "syllabus", + "syllabus", + "https://byu.instructure.com/courses/20736/assignments/syllabus" + ], + [ + "announcement", + "welcome-announcement", + "https://byu.instructure.com/courses/20736/discussion_topics/687933" + ], + [ + "quiz_question_order", + "day-1-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610839" + ] + ], + "content_to_review": [], + "error": "" +} \ No newline at end of file diff --git a/tests/test-mdxcanvas-payload.json b/tests/test-mdxcanvas-payload.json deleted file mode 100644 index abb37ab..0000000 --- a/tests/test-mdxcanvas-payload.json +++ /dev/null @@ -1,826 +0,0 @@ -{ - "deployed_content": [ - [ - "module", - "unit-1", - "https://byu.instructure.com/courses/20736" - ], - [ - "module", - "unit-2", - "https://byu.instructure.com/courses/20736" - ], - [ - "module", - "unit-3", - "https://byu.instructure.com/courses/20736" - ], - [ - "module", - "unit-4", - "https://byu.instructure.com/courses/20736" - ], - [ - "module_item", - "unit-3|Week 1 Wrap-Up", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "module_item", - "unit-4|day-9-header", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "assignment", - "lab-notebook-week1", - "https://byu.instructure.com/courses/20736/assignments/1332409" - ], - [ - "module_item", - "unit-3|lab-notebook-week1", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "quiz", - "day-9-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609750" - ], - [ - "page", - "day-9-marine-mammals", - "https://byu.instructure.com/courses/20736/pages/day-9-marine-mammals-10" - ], - [ - "module_item", - "unit-1|day-1-header", - "https://byu.instructure.com/courses/20736#module_381629" - ], - [ - "module_item", - "unit-4|day-9-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "module_item", - "unit-3|day-6-header", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "quiz", - "day-1-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609751" - ], - [ - "module_item", - "unit-4|day-9-marine-mammals", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "quiz", - "day-6-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609752" - ], - [ - "assignment", - "group-presentation", - "https://byu.instructure.com/courses/20736/assignments/1332413" - ], - [ - "page", - "day-6-coral-reefs", - "https://byu.instructure.com/courses/20736/pages/day-6-coral-reefs-10" - ], - [ - "page", - "day-1-welcome", - "https://byu.instructure.com/courses/20736/pages/day-1-welcome-to-the-sea-10" - ], - [ - "module_item", - "unit-3|day-6-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "module_item", - "unit-4|group-presentation", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "module_item", - "unit-1|day-1-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381629" - ], - [ - "module_item", - "unit-3|day-6-coral-reefs", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "module_item", - "unit-1|day-1-welcome", - "https://byu.instructure.com/courses/20736#module_381629" - ], - [ - "module_item", - "unit-4|day-10-header", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "module_item", - "unit-2|day-4-header", - "https://byu.instructure.com/courses/20736#module_381630" - ], - [ - "quiz", - "day-4-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609753" - ], - [ - "module_item", - "unit-3|day-7-header", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "page", - "day-4-phytoplankton", - "https://byu.instructure.com/courses/20736/pages/day-4-phytoplankton-and-primary-production-10" - ], - [ - "module_item", - "unit-1|day-2-header", - "https://byu.instructure.com/courses/20736#module_381629" - ], - [ - "quiz", - "day-10-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609754" - ], - [ - "module_item", - "unit-2|day-4-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381630" - ], - [ - "quiz", - "day-7-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609755" - ], - [ - "page", - "day-10-sharks-rays", - "https://byu.instructure.com/courses/20736/pages/day-10-sharks-rays-and-sea-turtles-10" - ], - [ - "page", - "day-7-estuaries-mangroves", - "https://byu.instructure.com/courses/20736/pages/day-7-estuaries-and-mangroves-10" - ], - [ - "module_item", - "unit-4|day-10-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "module_item", - "unit-2|day-4-phytoplankton", - "https://byu.instructure.com/courses/20736#module_381630" - ], - [ - "module_item", - "unit-3|day-7-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "quiz", - "day-2-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609756" - ], - [ - "page", - "day-2-ocean-geography", - "https://byu.instructure.com/courses/20736/pages/day-2-ocean-geography-10" - ], - [ - "module_item", - "unit-2|day-5-header", - "https://byu.instructure.com/courses/20736#module_381630" - ], - [ - "module_item", - "unit-4|day-10-sharks-rays", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "module_item", - "unit-3|day-7-estuaries-mangroves", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "module_item", - "unit-1|day-2-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381629" - ], - [ - "quiz", - "day-5-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609757" - ], - [ - "module_item", - "unit-1|day-2-ocean-geography", - "https://byu.instructure.com/courses/20736#module_381629" - ], - [ - "module_item", - "unit-3|day-8-header", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "page", - "day-5-zooplankton", - "https://byu.instructure.com/courses/20736/pages/day-5-zooplankton-and-food-webs-10" - ], - [ - "module_item", - "unit-4|Final Assessments", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "module_item", - "unit-2|day-5-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381630" - ], - [ - "assignment", - "lab-notebook-week2", - "https://byu.instructure.com/courses/20736/assignments/1332420" - ], - [ - "quiz", - "day-8-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609758" - ], - [ - "module_item", - "unit-1|day-3-header", - "https://byu.instructure.com/courses/20736#module_381629" - ], - [ - "module_item", - "unit-4|lab-notebook-week2", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "page", - "day-8-deep-sea", - "https://byu.instructure.com/courses/20736/pages/day-8-the-deep-sea-10" - ], - [ - "module_item", - "unit-2|day-5-zooplankton", - "https://byu.instructure.com/courses/20736#module_381630" - ], - [ - "module_item", - "unit-3|day-8-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "quiz", - "day-3-prep-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609759" - ], - [ - "assignment", - "final-reflection-essay", - "https://byu.instructure.com/courses/20736/assignments/1332423" - ], - [ - "quiz", - "midweek-quiz", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "page", - "day-3-seawater-chemistry", - "https://byu.instructure.com/courses/20736/pages/day-3-seawater-chemistry-10" - ], - [ - "module_item", - "unit-1|day-3-prep-quiz", - "https://byu.instructure.com/courses/20736#module_381629" - ], - [ - "quiz_question", - "midweek-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q12", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q7", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q13", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q10", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q8", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q9", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q11", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q14", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "day-10-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609754" - ], - [ - "quiz_question", - "day-10-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609754" - ], - [ - "quiz_question", - "midweek-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "midweek-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "day-10-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609754" - ], - [ - "quiz_question", - "midweek-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "day-10-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609754" - ], - [ - "quiz_question", - "day-10-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609754" - ], - [ - "quiz_question", - "day-10-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609754" - ], - [ - "quiz_question", - "day-10-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609754" - ], - [ - "quiz_question", - "day-9-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609750" - ], - [ - "quiz_question", - "day-9-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609750" - ], - [ - "quiz_question", - "day-9-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609750" - ], - [ - "quiz_question", - "day-9-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609750" - ], - [ - "quiz_question", - "day-9-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609750" - ], - [ - "quiz_question", - "day-8-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609758" - ], - [ - "quiz_question", - "day-9-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609750" - ], - [ - "quiz_question", - "day-8-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609758" - ], - [ - "quiz_question", - "day-9-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609750" - ], - [ - "quiz_question", - "day-8-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609758" - ], - [ - "quiz_question", - "day-8-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609758" - ], - [ - "quiz_question", - "day-8-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609758" - ], - [ - "quiz_question", - "day-8-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609758" - ], - [ - "quiz_question", - "day-8-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609758" - ], - [ - "quiz_question", - "day-7-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609755" - ], - [ - "quiz_question", - "day-7-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609755" - ], - [ - "quiz_question", - "day-7-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609755" - ], - [ - "quiz_question", - "day-7-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609755" - ], - [ - "quiz_question", - "day-6-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609752" - ], - [ - "quiz_question", - "day-7-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609755" - ], - [ - "quiz_question", - "day-7-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609755" - ], - [ - "quiz_question", - "day-7-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609755" - ], - [ - "quiz_question", - "day-6-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609752" - ], - [ - "quiz_question", - "day-6-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609752" - ], - [ - "quiz_question", - "day-6-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609752" - ], - [ - "quiz_question", - "day-6-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609752" - ], - [ - "quiz_question", - "day-6-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609752" - ], - [ - "quiz_question", - "day-5-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609757" - ], - [ - "quiz_question", - "day-5-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609757" - ], - [ - "quiz_question", - "day-6-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609752" - ], - [ - "quiz_question", - "day-5-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609757" - ], - [ - "quiz_question", - "day-5-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609757" - ], - [ - "quiz_question", - "day-5-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609757" - ], - [ - "quiz_question", - "day-5-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609757" - ], - [ - "quiz_question", - "day-5-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609757" - ], - [ - "quiz_question", - "day-4-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609753" - ], - [ - "quiz_question", - "day-4-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609753" - ], - [ - "quiz_question", - "day-4-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609753" - ], - [ - "quiz_question", - "day-4-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609753" - ], - [ - "quiz_question", - "day-4-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609753" - ], - [ - "quiz_question", - "day-4-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609753" - ], - [ - "quiz_question", - "day-4-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609753" - ], - [ - "quiz_question", - "day-3-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609759" - ], - [ - "quiz_question", - "day-3-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609759" - ], - [ - "quiz_question", - "day-3-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609759" - ], - [ - "quiz_question", - "day-3-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609759" - ], - [ - "quiz_question", - "day-3-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609759" - ], - [ - "quiz_question", - "day-3-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609759" - ], - [ - "quiz_question", - "day-3-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609759" - ], - [ - "quiz_question", - "day-2-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609756" - ], - [ - "quiz_question", - "day-2-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609756" - ], - [ - "quiz_question", - "day-2-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609756" - ], - [ - "quiz_question", - "day-2-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609756" - ], - [ - "module_item", - "unit-4|final-reflection-essay", - "https://byu.instructure.com/courses/20736#module_381632" - ], - [ - "module_item", - "unit-3|day-8-deep-sea", - "https://byu.instructure.com/courses/20736#module_381631" - ], - [ - "module_item", - "unit-2|midweek-quiz", - "https://byu.instructure.com/courses/20736#module_381630" - ], - [ - "module_item", - "unit-1|day-3-seawater-chemistry", - "https://byu.instructure.com/courses/20736#module_381629" - ], - [ - "quiz_question_order", - "midweek-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609760" - ], - [ - "quiz_question", - "day-2-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609756" - ], - [ - "quiz_question_order", - "day-10-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609754" - ], - [ - "quiz_question", - "day-2-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609756" - ], - [ - "quiz_question_order", - "day-9-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609750" - ], - [ - "quiz_question_order", - "day-8-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609758" - ], - [ - "quiz_question", - "day-2-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609756" - ], - [ - "quiz_question_order", - "day-7-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609755" - ], - [ - "quiz_question_order", - "day-6-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609752" - ], - [ - "quiz_question", - "day-1-prep-quiz|q6", - "https://byu.instructure.com/courses/20736/quizzes/609751" - ], - [ - "quiz_question", - "day-1-prep-quiz|q1", - "https://byu.instructure.com/courses/20736/quizzes/609751" - ], - [ - "quiz_question_order", - "day-4-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609753" - ], - [ - "quiz_question_order", - "day-5-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609757" - ], - [ - "quiz_question_order", - "day-3-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609759" - ], - [ - "quiz_question", - "day-1-prep-quiz|q5", - "https://byu.instructure.com/courses/20736/quizzes/609751" - ], - [ - "quiz_question_order", - "day-2-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609756" - ], - [ - "quiz_question", - "day-1-prep-quiz|q4", - "https://byu.instructure.com/courses/20736/quizzes/609751" - ], - [ - "quiz_question", - "day-1-prep-quiz|q2", - "https://byu.instructure.com/courses/20736/quizzes/609751" - ], - [ - "quiz_question", - "day-1-prep-quiz|q0", - "https://byu.instructure.com/courses/20736/quizzes/609751" - ], - [ - "quiz_question", - "day-1-prep-quiz|q3", - "https://byu.instructure.com/courses/20736/quizzes/609751" - ], - [ - "syllabus", - "syllabus", - "https://byu.instructure.com/courses/20736/assignments/syllabus" - ], - [ - "announcement", - "welcome-announcement", - "https://byu.instructure.com/courses/20736/discussion_topics/686882" - ], - [ - "quiz_question_order", - "day-1-prep-quiz|order", - "https://byu.instructure.com/courses/20736/quizzes/609751" - ] - ], - "content_to_review": [], - "error": "" -} \ No newline at end of file diff --git a/tests/test-urgent-mdxcanvas-payload.json b/tests/test-urgent-mdxcanvas-payload.json new file mode 100644 index 0000000..4d5be53 --- /dev/null +++ b/tests/test-urgent-mdxcanvas-payload.json @@ -0,0 +1,17 @@ +{ + "deployed_content": [ + [ + "quiz_question_order", + "day-2-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610843" + ] + ], + "content_to_review": [ + [ + "quiz_question_order", + "day-4-prep-quiz|order", + "https://byu.instructure.com/courses/20736/quizzes/610844" + ] + ], + "error": "" +} \ No newline at end of file diff --git a/tests/test_course_text_formatters.py b/tests/test_course_text_formatters.py index a258483..2ce3abf 100644 --- a/tests/test_course_text_formatters.py +++ b/tests/test_course_text_formatters.py @@ -55,8 +55,8 @@ def test_review_has_content_ping_and_review_section(self): ("assignment", "Needs Review", "https://courses.example/review-me"), ], "content_to_review": [ - ("Needs Review", "https://courses.example/review-me"), - ("Professor Approval", "https://courses.example/professor"), + ("assignment", "Needs Review", "https://courses.example/review-me"), + ("assignment", "Professor Approval", "https://courses.example/professor"), ], "error": "", }, diff --git a/tests/test_discord_limits.py b/tests/test_discord_limits.py index bd302dc..62f9040 100644 --- a/tests/test_discord_limits.py +++ b/tests/test_discord_limits.py @@ -148,29 +148,57 @@ def test_build_single_embed_message(self): mb = MessageBuilder(color=0xFF0000, timestamp="2025-01-01T00:00:00Z") eb = mb.new_embed(title="Title", description="Desc") eb.add_field("Key", "Value") - message = mb.build() - assert len(message.embeds) == 1 - assert message.embeds[0].title == "Title" + messages = mb.build() + assert len(messages) == 1 + assert len(messages[0].embeds) == 1 + assert messages[0].embeds[0].title == "Title" def test_new_embed_creates_continuation(self): mb = MessageBuilder(color=0xFF0000, timestamp="2025-01-01T00:00:00Z") mb.new_embed(title="First", description="Desc") mb.new_embed(title="Second", description="") - message = mb.build() - assert len(message.embeds) == 2 + messages = mb.build() + # Both small embeds should fit in one message + total_embeds = sum(len(m.embeds) for m in messages) + assert total_embeds == 2 def test_set_content(self): mb = MessageBuilder(color=0, timestamp="") mb.set_content("hello world") mb.new_embed(title="T", description="D") - message = mb.build() - assert message.content == "hello world" + messages = mb.build() + assert messages[0].content == "hello world" + + def test_content_only_on_first_message(self): + mb = MessageBuilder(color=0, timestamp="") + mb.set_content("hello world") + # Create two embeds that together exceed 6000 chars + mb.new_embed(title="T", description="x" * 5000) + mb.new_embed(title="T", description="x" * 5000) + messages = mb.build() + assert len(messages) == 2 + assert messages[0].content == "hello world" + assert messages[1].content is None def test_current_embed_returns_active_builder(self): mb = MessageBuilder(color=0, timestamp="") eb = mb.new_embed(title="T", description="D") assert mb.current_embed is eb + def test_large_embeds_split_across_messages(self): + mb = MessageBuilder(color=0, timestamp="") + # Create 3 embeds each ~3000 chars — can't fit 2 in one message + for i in range(3): + mb.new_embed(title=f"E{i}", description="x" * 2990) + messages = mb.build() + assert len(messages) >= 2 + for msg in messages: + combined = sum( + len(e.title or "") + len(e.description or "") + for e in msg.embeds + ) + assert combined <= 6000 + class TestValidateNotification: def test_valid_notification_returns_empty(self): @@ -333,8 +361,8 @@ def test_success_case_passes_all_limits(self): def test_review_case_passes_all_limits(self): data = self._load_payload() data["content_to_review"] = [ - ("lab-notebook-week1", "https://byu.instructure.com/courses/20736/assignments/1332409"), - ("group-presentation", "https://byu.instructure.com/courses/20736/assignments/1332413"), + ("assignment", "lab-notebook-week1", "https://byu.instructure.com/courses/20736/assignments/1332409"), + ("assignment", "group-presentation", "https://byu.instructure.com/courses/20736/assignments/1332413"), ] notification = format_notification( data=data, From bc4117fa2929b55c6955a1987b86ae3e9a426635 Mon Sep 17 00:00:00 2001 From: robbykap Date: Mon, 13 Apr 2026 18:09:53 -0600 Subject: [PATCH 41/43] fixed cicd for docker --- notifications/formatting/docker_format.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/notifications/formatting/docker_format.py b/notifications/formatting/docker_format.py index e81b73b..7ec8a54 100644 --- a/notifications/formatting/docker_format.py +++ b/notifications/formatting/docker_format.py @@ -112,6 +112,9 @@ def format_notification( # -- Build message -------------------------------------------------------- mb = MessageBuilder(color=color, timestamp=timestamp) + if content: + mb.set_content(content) + eb = mb.new_embed( title=title, description=description, From 2cfa55fea1c20cf1d70cb6155f4d57d86480ba28 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 15 Apr 2026 11:20:26 -0600 Subject: [PATCH 42/43] Remove PyPI notification migration from PR --- .../actions/send-pypi-notification/action.yml | 54 -------- .github/workflows/poetry_publish.yaml | 49 ++++--- notifications/formatting/formatting_utils.py | 8 -- notifications/formatting/pypi_format.py | 33 ----- notifications/formatting/style.md | 8 -- notifications/send_pypi.py | 41 ------ .../build_send_update_notification_docker.sh | 21 +++ pypi_updates/send_update_notification.py | 124 ++++++++++++++++++ tests/test_embed_chunking.py | 15 --- 9 files changed, 175 insertions(+), 178 deletions(-) delete mode 100644 .github/actions/send-pypi-notification/action.yml delete mode 100644 notifications/formatting/pypi_format.py delete mode 100644 notifications/send_pypi.py create mode 100644 pypi_updates/build_send_update_notification_docker.sh create mode 100644 pypi_updates/send_update_notification.py diff --git a/.github/actions/send-pypi-notification/action.yml b/.github/actions/send-pypi-notification/action.yml deleted file mode 100644 index 40be3a1..0000000 --- a/.github/actions/send-pypi-notification/action.yml +++ /dev/null @@ -1,54 +0,0 @@ -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/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index e19067c..47a34e8 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@v4 + uses: actions/checkout@v3 - name: Install Poetry and Version Plugin run: pip install "poetry<2" poetry-version-from-file @@ -66,24 +66,35 @@ jobs: notify-discord: needs: [check-toml-version, poetry-publish] runs-on: ubuntu-latest - if: always() + if: always() # Notify on both success and failure steps: - - name: Checkout utils repo - uses: actions/checkout@v4 - with: - repository: BYU-CS-Course-Ops/utils - path: utils + - 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 }}" - - name: Install notification dependencies - shell: bash - run: pip install discord-webhook ${{ inputs.extra-packages }} + echo "$CMD" - - name: Send PyPI notification - uses: utils/.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 }} + 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" diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index 51dde0f..b9196de 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -22,14 +22,6 @@ def get_course_style(ntype: str) -> dict[str, str]: 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 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 deleted file mode 100644 index e5a68ef..0000000 --- a/notifications/formatting/pypi_format.py +++ /dev/null @@ -1,33 +0,0 @@ -from notifications.resources import Notification, Embed, Author, Footer, WebhookMessage -from notifications.formatting.formatting_utils import 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}`**\n\n-# [Action Log]({action_url})" - else: - description = f"An **error occurred** while updating {style['display_name']}.\n\n-# [Action Log]({action_url})" - - return Notification( - username=style["username"], - messages=[ - WebhookMessage( - embeds=[ - Embed( - title=style["title"], - description=description, - color=hex_to_int(style["hex_color"]), - timestamp="", - author=Author(name=author, icon_url=author_icon), - footer=Footer( - text=style["footer_text"], - icon_url=style["footer_icon_url"], - ), - fields=[], - ) - ], - ) - ], - ) diff --git a/notifications/formatting/style.md b/notifications/formatting/style.md index bdd5d0d..a3d983d 100644 --- a/notifications/formatting/style.md +++ b/notifications/formatting/style.md @@ -4,11 +4,3 @@ |--------|--------------------------|------------------------------|-----------|-------------------------------------|-------------------------|------------------------------| | 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/send_pypi.py b/notifications/send_pypi.py deleted file mode 100644 index d8d5fb4..0000000 --- a/notifications/send_pypi.py +++ /dev/null @@ -1,41 +0,0 @@ -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 and notification.messages: - notification.messages[0].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 new file mode 100644 index 0000000..b57f4a6 --- /dev/null +++ b/pypi_updates/build_send_update_notification_docker.sh @@ -0,0 +1,21 @@ +# 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_embed_chunking.py b/tests/test_embed_chunking.py index 8bf18c9..733383d 100644 --- a/tests/test_embed_chunking.py +++ b/tests/test_embed_chunking.py @@ -83,18 +83,3 @@ def test_all_fields_preserved_when_within_limits(self): b.add_field(f"f{i}", f"v{i}") embed = b.build() assert len(embed.fields) == 10 - - def test_typical_pypi_embed_stays_small(self): - b = EmbedBuilder( - title="PyPI Update", - description="A new version has been published.", - color=0x3B82F6, - timestamp="2025-01-01T00:00:00Z", - author=Author(name="PyPI Bot"), - footer=Footer(text="BeanLab Dev Utils"), - ) - b.add_field("Package", "my-package") - b.add_field("Version", "1.2.3") - b.add_field("Status", "Published") - embed = b.build() - assert calc_embed_size(embed) < EMBED_CHAR_LIMIT From a1472b07976768b2835538d4b5044924a71ee944 Mon Sep 17 00:00:00 2001 From: robbykap Date: Wed, 15 Apr 2026 11:33:46 -0600 Subject: [PATCH 43/43] Over View -> Overview --- ...2026-04-08-canvas-notification-redesign.md | 1751 ----------------- ...-08-canvas-notification-redesign-design.md | 159 -- notifications/formatting/canvas_format.py | 2 +- tests/send_test_notification.py | 2 +- tests/test_course_text_formatters.py | 4 +- 5 files changed, 4 insertions(+), 1914 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-08-canvas-notification-redesign.md delete mode 100644 docs/superpowers/specs/2026-04-08-canvas-notification-redesign-design.md diff --git a/docs/superpowers/plans/2026-04-08-canvas-notification-redesign.md b/docs/superpowers/plans/2026-04-08-canvas-notification-redesign.md deleted file mode 100644 index f217868..0000000 --- a/docs/superpowers/plans/2026-04-08-canvas-notification-redesign.md +++ /dev/null @@ -1,1751 +0,0 @@ -# Canvas Notification Redesign Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Redesign Canvas deploy notifications with tabulate overview tables, limit-aware formatting via builder classes, and no emojis. - -**Architecture:** Extract Discord limit constants and builders into `notifications/discord_limits.py`. Formatters build notifications incrementally using `EmbedBuilder`/`MessageBuilder` to respect character budgets. `send_notification.py` becomes a thin webhook sender. - -**Tech Stack:** Python, tabulate (already installed), discord-webhook, pytest - ---- - -## File Structure - -| File | Action | Responsibility | -|---|---|---| -| `notifications/discord_limits.py` | Create | Constants, `EmbedBuilder`, `MessageBuilder`, `validate_notification`, `calc_embed_size`, `split_content` | -| `notifications/send_notification.py` | Modify | Thin webhook sender only — imports limits from `discord_limits` | -| `notifications/formatting/canvas_format.py` | Rewrite | Three-case notification formatter with tabulate overview table | -| `notifications/formatting/formatting_utils.py` | Modify | Remove `chunk_field_lines` and `emoji_for`, keep `get_course_style`, `truncate_error` | -| `notifications/formatting/docker_format.py` | Modify | Use `EmbedBuilder` instead of `chunk_field_lines` | -| `notifications/send_course.py` | Modify | Pass `cicd_role_id` to formatter, remove post-hoc content injection | -| `tests/test_discord_limits.py` | Create | Builder unit tests + limit validation tests | -| `tests/test_embed_chunking.py` | Modify | Update imports, replace `_chunk_embed` tests with builder tests | -| `tests/test_course_text_formatters.py` | Modify | Update canvas tests for new format/signature, fix docker tests | - ---- - -### Task 1: Create `discord_limits.py` — constants and moved functions - -**Files:** -- Create: `notifications/discord_limits.py` -- Test: `tests/test_discord_limits.py` - -- [ ] **Step 1: Write failing tests for constants and `calc_embed_size`** - -Create `tests/test_discord_limits.py`: - -```python -from notifications.discord_limits import ( - TITLE_LIMIT, - DESCRIPTION_LIMIT, - FIELD_NAME_LIMIT, - FIELD_VALUE_LIMIT, - FIELDS_PER_EMBED, - FOOTER_LIMIT, - AUTHOR_NAME_LIMIT, - EMBED_CHAR_LIMIT, - MAX_EMBEDS_PER_MESSAGE, - CONTENT_LIMIT, - calc_embed_size, - split_content, -) -from notifications.resources import Author, Embed, Field, Footer - - -class TestConstants: - def test_embed_char_limit(self): - assert EMBED_CHAR_LIMIT == 6000 - - def test_field_value_limit(self): - assert FIELD_VALUE_LIMIT == 1024 - - def test_fields_per_embed(self): - assert FIELDS_PER_EMBED == 25 - - def test_content_limit(self): - assert CONTENT_LIMIT == 2000 - - def test_max_embeds_per_message(self): - assert MAX_EMBEDS_PER_MESSAGE == 10 - - -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 - - -class TestSplitContent: - def test_short_content_unchanged(self): - result = split_content("hello", 2000) - assert result == ["hello"] - - def test_long_content_splits_on_paragraphs(self): - paragraphs = ["paragraph " + str(i) for i in range(200)] - content = "\n\n".join(paragraphs) - result = split_content(content, 100) - assert len(result) > 1 - for chunk in result: - assert len(chunk) <= 100 -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `python -m pytest tests/test_discord_limits.py -v` -Expected: FAIL — `ModuleNotFoundError: No module named 'notifications.discord_limits'` - -- [ ] **Step 3: Create `discord_limits.py` with constants and functions** - -Create `notifications/discord_limits.py`: - -```python -from __future__ import annotations - -from .resources import Embed - -# ── Discord API limits ────────────────────────────────────────────────────── -TITLE_LIMIT = 256 -DESCRIPTION_LIMIT = 4096 -FIELD_NAME_LIMIT = 256 -FIELD_VALUE_LIMIT = 1024 -FIELDS_PER_EMBED = 25 -FOOTER_LIMIT = 2048 -AUTHOR_NAME_LIMIT = 256 -EMBED_CHAR_LIMIT = 6000 -MAX_EMBEDS_PER_MESSAGE = 10 -CONTENT_LIMIT = 2000 - - -def calc_embed_size(embed: Embed) -> int: - 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 split_content(content: str, max_chars: int = CONTENT_LIMIT) -> list[str]: - if len(content) <= max_chars: - return [content] - - paragraphs = content.split("\n\n") - chunks: list[str] = [] - 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 -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `python -m pytest tests/test_discord_limits.py -v` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add notifications/discord_limits.py tests/test_discord_limits.py -git commit -m "Add discord_limits module with constants, calc_embed_size, split_content" -``` - ---- - -### Task 2: Add `EmbedBuilder` to `discord_limits.py` - -**Files:** -- Modify: `notifications/discord_limits.py` -- Modify: `tests/test_discord_limits.py` - -- [ ] **Step 1: Write failing tests for `EmbedBuilder`** - -Append to `tests/test_discord_limits.py`: - -```python -from notifications.discord_limits import EmbedBuilder - - -class TestEmbedBuilder: - def test_build_produces_embed(self): - b = EmbedBuilder( - title="Title", - description="Desc", - color=0xFF0000, - timestamp="2025-01-01T00:00:00Z", - author=Author(name="Bot"), - footer=Footer(text="Footer"), - ) - embed = b.build() - assert embed.title == "Title" - assert embed.description == "Desc" - assert embed.color == 0xFF0000 - assert embed.author.name == "Bot" - assert embed.footer.text == "Footer" - assert embed.fields == [] - - def test_add_field_success(self): - b = EmbedBuilder( - title="T", description="D", color=0, timestamp="", - ) - result = b.add_field("Name", "Value", inline=False) - assert result is True - embed = b.build() - assert len(embed.fields) == 1 - assert embed.fields[0].name == "Name" - assert embed.fields[0].value == "Value" - - def test_can_add_field_checks_char_limit(self): - b = EmbedBuilder( - title="x" * 5000, description="", color=0, timestamp="", - ) - # Only ~1000 chars left, try to add 1500-char field - assert b.can_add_field("name", "x" * 1500) is False - - def test_can_add_field_checks_field_count(self): - b = EmbedBuilder( - title="T", description="D", color=0, timestamp="", - ) - for i in range(25): - b.add_field(f"f{i}", "v") - assert b.can_add_field("f25", "v") is False - - def test_add_field_returns_false_when_full(self): - b = EmbedBuilder( - title="x" * 5900, description="", color=0, timestamp="", - ) - result = b.add_field("name", "x" * 500) - assert result is False - assert len(b.build().fields) == 0 - - def test_remaining_chars_decreases(self): - b = EmbedBuilder( - title="Hello", description="World", color=0, timestamp="", - ) - initial = b.remaining_chars() - b.add_field("Key", "Value") - assert b.remaining_chars() == initial - len("Key") - len("Value") -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `python -m pytest tests/test_discord_limits.py::TestEmbedBuilder -v` -Expected: FAIL — `ImportError: cannot import name 'EmbedBuilder'` - -- [ ] **Step 3: Implement `EmbedBuilder`** - -Add to `notifications/discord_limits.py`: - -```python -from .resources import Author, Embed, Field, Footer - - -class EmbedBuilder: - def __init__( - self, - title: str, - description: str, - color: int, - timestamp: str, - author: Author | None = None, - footer: Footer | None = None, - ): - self._title = title - self._description = description - self._color = color - self._timestamp = timestamp - self._author = author - self._footer = footer - self._fields: list[Field] = [] - - self._base_size = len(title or "") + len(description or "") - if author: - self._base_size += len(author.name or "") - if footer: - self._base_size += len(footer.text or "") - self._fields_size = 0 - - def remaining_chars(self) -> int: - return EMBED_CHAR_LIMIT - self._base_size - self._fields_size - - def can_add_field(self, name: str, value: str) -> bool: - if len(self._fields) >= FIELDS_PER_EMBED: - return False - field_size = len(name or "") + len(value or "") - return self._fields_size + field_size <= self.remaining_chars() - - def add_field(self, name: str, value: str, inline: bool = False) -> bool: - if not self.can_add_field(name, value): - return False - self._fields.append(Field(name=name, value=value, inline=inline)) - self._fields_size += len(name or "") + len(value or "") - return True - - def build(self) -> Embed: - return Embed( - title=self._title, - description=self._description, - color=self._color, - fields=list(self._fields), - timestamp=self._timestamp, - author=self._author, - footer=self._footer, - ) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `python -m pytest tests/test_discord_limits.py::TestEmbedBuilder -v` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add notifications/discord_limits.py tests/test_discord_limits.py -git commit -m "Add EmbedBuilder with char budget and field count tracking" -``` - ---- - -### Task 3: Add `MessageBuilder` to `discord_limits.py` - -**Files:** -- Modify: `notifications/discord_limits.py` -- Modify: `tests/test_discord_limits.py` - -- [ ] **Step 1: Write failing tests for `MessageBuilder`** - -Append to `tests/test_discord_limits.py`: - -```python -from notifications.discord_limits import MessageBuilder - - -class TestMessageBuilder: - def test_build_single_embed_message(self): - mb = MessageBuilder(color=0xFF0000, timestamp="2025-01-01T00:00:00Z") - eb = mb.new_embed(title="Title", description="Desc") - eb.add_field("Key", "Value") - message = mb.build() - assert len(message.embeds) == 1 - assert message.embeds[0].title == "Title" - - def test_new_embed_creates_continuation(self): - mb = MessageBuilder(color=0xFF0000, timestamp="2025-01-01T00:00:00Z") - mb.new_embed(title="First", description="Desc") - mb.new_embed(title="Second", description="") - message = mb.build() - assert len(message.embeds) == 2 - - def test_set_content(self): - mb = MessageBuilder(color=0, timestamp="") - mb.set_content("hello world") - mb.new_embed(title="T", description="D") - message = mb.build() - assert message.content == "hello world" - - def test_current_embed_returns_active_builder(self): - mb = MessageBuilder(color=0, timestamp="") - eb = mb.new_embed(title="T", description="D") - assert mb.current_embed is eb -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `python -m pytest tests/test_discord_limits.py::TestMessageBuilder -v` -Expected: FAIL — `ImportError: cannot import name 'MessageBuilder'` - -- [ ] **Step 3: Implement `MessageBuilder`** - -Add to `notifications/discord_limits.py`: - -```python -from .resources import WebhookMessage - - -class MessageBuilder: - def __init__(self, color: int, timestamp: str): - self._color = color - self._timestamp = timestamp - self._content: str | None = None - self._embeds: list[EmbedBuilder] = [] - - def set_content(self, content: str): - self._content = content - - @property - def current_embed(self) -> EmbedBuilder | None: - return self._embeds[-1] if self._embeds else None - - def new_embed( - self, - title: str, - description: str, - author: Author | None = None, - footer: Footer | None = None, - ) -> EmbedBuilder: - eb = EmbedBuilder( - title=title, - description=description, - color=self._color, - timestamp=self._timestamp, - author=author, - footer=footer, - ) - self._embeds.append(eb) - return eb - - def build(self) -> WebhookMessage: - return WebhookMessage( - content=self._content, - embeds=[eb.build() for eb in self._embeds], - ) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `python -m pytest tests/test_discord_limits.py::TestMessageBuilder -v` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add notifications/discord_limits.py tests/test_discord_limits.py -git commit -m "Add MessageBuilder for multi-embed message construction" -``` - ---- - -### Task 4: Add `validate_notification` to `discord_limits.py` - -**Files:** -- Modify: `notifications/discord_limits.py` -- Modify: `tests/test_discord_limits.py` - -- [ ] **Step 1: Write failing tests for `validate_notification`** - -Append to `tests/test_discord_limits.py`: - -```python -from notifications.discord_limits import validate_notification -from notifications.resources import Notification, WebhookMessage - - -class TestValidateNotification: - def test_valid_notification_returns_empty(self): - notification = Notification( - username="Bot", - messages=[ - WebhookMessage( - content=None, - embeds=[ - Embed( - title="Title", - description="Desc", - color=0, - fields=[Field(name="k", value="v")], - timestamp="", - ) - ], - ) - ], - ) - assert validate_notification(notification) == [] - - def test_title_too_long(self): - notification = Notification( - username="Bot", - messages=[ - WebhookMessage( - embeds=[ - Embed( - title="x" * 257, - description="", - color=0, - fields=[], - timestamp="", - ) - ], - ) - ], - ) - violations = validate_notification(notification) - assert any("title" in v.lower() for v in violations) - - def test_field_value_too_long(self): - notification = Notification( - username="Bot", - messages=[ - WebhookMessage( - embeds=[ - Embed( - title="T", - description="", - color=0, - fields=[Field(name="k", value="x" * 1025)], - timestamp="", - ) - ], - ) - ], - ) - violations = validate_notification(notification) - assert any("field value" in v.lower() for v in violations) - - def test_too_many_fields(self): - notification = Notification( - username="Bot", - messages=[ - WebhookMessage( - embeds=[ - Embed( - title="T", - description="", - color=0, - fields=[Field(name="k", value="v") for _ in range(26)], - timestamp="", - ) - ], - ) - ], - ) - violations = validate_notification(notification) - assert any("field count" in v.lower() for v in violations) - - def test_embed_total_chars_too_large(self): - notification = Notification( - username="Bot", - messages=[ - WebhookMessage( - embeds=[ - Embed( - title="x" * 256, - description="x" * 4096, - color=0, - fields=[Field(name="k", value="x" * 1024) for _ in range(3)], - timestamp="", - ) - ], - ) - ], - ) - violations = validate_notification(notification) - assert any("embed char" in v.lower() for v in violations) - - def test_content_too_long(self): - notification = Notification( - username="Bot", - messages=[ - WebhookMessage( - content="x" * 2001, - embeds=[], - ) - ], - ) - violations = validate_notification(notification) - assert any("content" in v.lower() for v in violations) - - def test_too_many_embeds(self): - notification = Notification( - username="Bot", - messages=[ - WebhookMessage( - embeds=[ - Embed(title="T", description="", color=0, fields=[], timestamp="") - for _ in range(11) - ], - ) - ], - ) - violations = validate_notification(notification) - assert any("embed count" in v.lower() for v in violations) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `python -m pytest tests/test_discord_limits.py::TestValidateNotification -v` -Expected: FAIL — `ImportError: cannot import name 'validate_notification'` - -- [ ] **Step 3: Implement `validate_notification`** - -Add to `notifications/discord_limits.py`: - -```python -from .resources import Notification - - -def validate_notification(notification: Notification) -> list[str]: - violations: list[str] = [] - - for msg_idx, message in enumerate(notification.messages): - prefix = f"Message {msg_idx}" - - if message.content and len(message.content) > CONTENT_LIMIT: - violations.append( - f"{prefix}: Content length {len(message.content)} exceeds {CONTENT_LIMIT}" - ) - - if len(message.embeds) > MAX_EMBEDS_PER_MESSAGE: - violations.append( - f"{prefix}: Embed count {len(message.embeds)} exceeds {MAX_EMBEDS_PER_MESSAGE}" - ) - - for emb_idx, embed in enumerate(message.embeds): - ep = f"{prefix}, Embed {emb_idx}" - - if embed.title and len(embed.title) > TITLE_LIMIT: - violations.append( - f"{ep}: Title length {len(embed.title)} exceeds {TITLE_LIMIT}" - ) - - if embed.description and len(embed.description) > DESCRIPTION_LIMIT: - violations.append( - f"{ep}: Description length {len(embed.description)} exceeds {DESCRIPTION_LIMIT}" - ) - - if embed.footer and len(embed.footer.text or "") > FOOTER_LIMIT: - violations.append( - f"{ep}: Footer length {len(embed.footer.text)} exceeds {FOOTER_LIMIT}" - ) - - if embed.author and len(embed.author.name or "") > AUTHOR_NAME_LIMIT: - violations.append( - f"{ep}: Author name length {len(embed.author.name)} exceeds {AUTHOR_NAME_LIMIT}" - ) - - if len(embed.fields) > FIELDS_PER_EMBED: - violations.append( - f"{ep}: Field count {len(embed.fields)} exceeds {FIELDS_PER_EMBED}" - ) - - for fld_idx, field in enumerate(embed.fields): - if field.name and len(field.name) > FIELD_NAME_LIMIT: - violations.append( - f"{ep}, Field {fld_idx}: Field name length {len(field.name)} exceeds {FIELD_NAME_LIMIT}" - ) - if field.value and len(field.value) > FIELD_VALUE_LIMIT: - violations.append( - f"{ep}, Field {fld_idx}: Field value length {len(field.value)} exceeds {FIELD_VALUE_LIMIT}" - ) - - total = calc_embed_size(embed) - if total > EMBED_CHAR_LIMIT: - violations.append( - f"{ep}: Embed char total {total} exceeds {EMBED_CHAR_LIMIT}" - ) - - return violations -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `python -m pytest tests/test_discord_limits.py::TestValidateNotification -v` -Expected: All PASS - -- [ ] **Step 5: Commit** - -```bash -git add notifications/discord_limits.py tests/test_discord_limits.py -git commit -m "Add validate_notification for post-build limit checking" -``` - ---- - -### Task 5: Refactor `send_notification.py` and update `test_embed_chunking.py` - -**Files:** -- Modify: `notifications/send_notification.py` -- Modify: `tests/test_embed_chunking.py` - -- [ ] **Step 1: Rewrite `send_notification.py` as thin sender** - -Replace the contents of `notifications/send_notification.py` with: - -```python -from discord_webhook import DiscordWebhook, DiscordEmbed - -from .discord_limits import CONTENT_LIMIT, split_content -from .resources import Embed, Notification - - -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 or None, - ) - - 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: - embed.add_embed_field( - name=field.name or "\u200b", - value=field.value or "\u200b", - inline=field.inline, - ) - - return 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): - for message in notification.messages: - content_chunks = split_content(message.content, CONTENT_LIMIT) if message.content else [None] - - for index, content in enumerate(content_chunks): - try: - response = _execute_webhook( - webhook_url=webhook_url, - notification=notification, - content=content, - embeds=message.embeds if index == 0 else [], - ) - except Exception as e: - print(f"Error sending notification: {e}") - continue - - if response.status_code >= 400: - print(f"Discord returned status {response.status_code}: {response.text}") - else: - print("Sent message successfully.") -``` - -- [ ] **Step 2: Rewrite `test_embed_chunking.py` to test builders instead** - -Replace `tests/test_embed_chunking.py` with: - -```python -from notifications.discord_limits import ( - EMBED_CHAR_LIMIT, - FIELDS_PER_EMBED, - EmbedBuilder, - calc_embed_size, -) -from notifications.resources import Author, Embed, Field, Footer - - -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="", - ) - assert calc_embed_size(embed) == 2 - - -class TestEmbedBuilderLimits: - def test_builder_never_exceeds_embed_char_limit(self): - b = EmbedBuilder( - title="Title", - description="Description text", - color=0, - timestamp="", - author=Author(name="Bot"), - footer=Footer(text="Footer"), - ) - added = 0 - for i in range(100): - if not b.add_field(f"field-{i}", "x" * 500): - break - added += 1 - embed = b.build() - assert calc_embed_size(embed) <= EMBED_CHAR_LIMIT - assert added > 0 - - def test_builder_respects_field_count_limit(self): - b = EmbedBuilder(title="T", description="D", color=0, timestamp="") - for i in range(30): - b.add_field(f"f{i}", "v") - embed = b.build() - assert len(embed.fields) <= FIELDS_PER_EMBED - - def test_all_fields_preserved_when_within_limits(self): - b = EmbedBuilder(title="T", description="D", color=0, timestamp="") - for i in range(10): - b.add_field(f"f{i}", f"v{i}") - embed = b.build() - assert len(embed.fields) == 10 - - def test_typical_pypi_embed_stays_small(self): - b = EmbedBuilder( - title="PyPI Update", - description="A new version has been published.", - color=0x3B82F6, - timestamp="2025-01-01T00:00:00Z", - author=Author(name="PyPI Bot"), - footer=Footer(text="BeanLab Dev Utils"), - ) - b.add_field("Package", "my-package") - b.add_field("Version", "1.2.3") - b.add_field("Status", "Published") - embed = b.build() - assert calc_embed_size(embed) < EMBED_CHAR_LIMIT -``` - -- [ ] **Step 3: Run all tests** - -Run: `python -m pytest tests/test_embed_chunking.py tests/test_discord_limits.py -v` -Expected: All PASS - -- [ ] **Step 4: Commit** - -```bash -git add notifications/send_notification.py tests/test_embed_chunking.py -git commit -m "Refactor send_notification to thin sender, update chunking tests for builders" -``` - ---- - -### Task 6: Clean up `formatting_utils.py` - -**Files:** -- Modify: `notifications/formatting/formatting_utils.py` - -- [ ] **Step 1: Remove `chunk_field_lines`, `emoji_for`, `RESOURCE_EMOJI`, and `MAX_FIELD_CHARS`** - -The file should become: - -```python -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 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```" -``` - -- [ ] **Step 2: Run existing tests to verify nothing breaks** - -Run: `python -m pytest tests/ -v --ignore=tests/test_course_text_formatters.py` -Expected: PASS (formatter tests will be updated later since they test the old format) - -- [ ] **Step 3: Commit** - -```bash -git add notifications/formatting/formatting_utils.py -git commit -m "Remove chunk_field_lines and emoji_for from formatting_utils" -``` - ---- - -### Task 7: Rewrite `canvas_format.py` - -**Files:** -- Rewrite: `notifications/formatting/canvas_format.py` - -- [ ] **Step 1: Write the new canvas formatter** - -Replace `notifications/formatting/canvas_format.py` with: - -```python -from __future__ import annotations - -from collections import Counter -from datetime import datetime, timezone - -from tabulate import tabulate - -from notifications.discord_limits import EmbedBuilder, MessageBuilder -from notifications.formatting.formatting_utils import get_course_style, truncate_error -from notifications.formatting.plain_text_utils import ( - dedupe_remaining_content, - status_color, -) -from notifications.resources import Author, Footer, Notification - - -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 _build_overview_table(deployed_content: list, content_to_review: list) -> str: - """Build a tabulate overview table counting distinct items by resource type.""" - seen: set[str] = set() - type_counts: Counter = Counter() - - for content_type, name, *_ in deployed_content: - if name not in seen: - seen.add(name) - type_counts[content_type] += 1 - - for name, *_ in content_to_review: - if name not in seen: - seen.add(name) - type_counts["assignment"] += 1 - - rows = sorted(type_counts.items(), key=lambda r: (-r[1], r[0])) - table = tabulate(rows, headers=["Resource Type", "Count"], tablefmt="pipe") - return f"```\n{table}\n```" - - -def _format_item(resource_type: str, name: str, link: str | None) -> str: - label = f"`{resource_type}`" - if link: - return f"{label} [{name}]({link})" - return f"{label} {name}" - - -def _add_items_to_builder( - builder: EmbedBuilder, - message_builder: MessageBuilder, - header: str, - lines: list[str], - author: Author | None, - footer: Footer | None, - continuation_title: str, -): - """Pack item lines into fields, starting new embeds as needed.""" - current_lines: list[str] = [] - current_len = 0 - is_first_field = True - - def flush_field(): - nonlocal current_lines, current_len, is_first_field, builder - if not current_lines: - return - value = "\n".join(current_lines) - name = header if is_first_field else "\u200b" - - if not builder.can_add_field(name, value): - builder = message_builder.new_embed( - title=continuation_title, - description="", - footer=footer, - ) - - builder.add_field(name, value, inline=False) - is_first_field = False - current_lines = [] - current_len = 0 - - for line in lines: - needed = len(line) + (1 if current_lines else 0) - test_value = "\n".join(current_lines + [line]) - test_name = header if is_first_field else "\u200b" - - if current_lines and not builder.can_add_field(test_name, test_value): - flush_field() - - current_lines.append(line) - current_len += needed - - flush_field() - return builder - - -def format_notification( - data, - course_id, - course_name, - course_url, - author, - author_icon, - branch, - action_url, - cicd_role_id=None, -) -> Notification: - style = get_course_style("canvas") - timestamp = datetime.now(timezone.utc).isoformat() - footer = Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]) - author_obj = Author(name=author, icon_url=author_icon) - - # ── Title ──────────────────────────────────────────────────────────── - if data["error"]: - title = f"CS {course_id} | {course_name} -- Deploy failed" - elif data["content_to_review"]: - title = f"CS {course_id} | {course_name} -- Deploy complete -- items need review" - else: - title = f"CS {course_id} | {course_name} -- Deploy complete" - - continuation_title = f"{title} (continued)" - - # ── Color ──────────────────────────────────────────────────────────── - color = status_color( - has_error=bool(data["error"]), - needs_review=requires_review(data), - ) - - # ── Content message ────────────────────────────────────────────────── - content = None - if data["error"] and cicd_role_id: - content = f"<@&{cicd_role_id}> ERROR -- MDXCanvas failed to deploy. View [here]({action_url})" - elif data["content_to_review"] and cicd_role_id: - content = f"<@&{cicd_role_id}> -- Deployed Resources to Review" - - # ── Description ────────────────────────────────────────────────────── - description = f"**Branch:** `{branch}`" - - if data["error"]: - truncated = truncate_error(data["error"]) - description += f"\n\n**Error:**\n{truncated}" - - # ── Build message ──────────────────────────────────────────────────── - mb = MessageBuilder(color=color, timestamp=timestamp) - if content: - mb.set_content(content) - - eb = mb.new_embed( - title=title, - description=description, - author=author_obj, - footer=footer, - ) - - # Error case: no fields, just the error in description - if data["error"]: - return Notification( - username=style["username"], - avatar_url=style["avatar_url"], - messages=[mb.build()], - ) - - # ── Overview table ─────────────────────────────────────────────────── - if data["deployed_content"] or data["content_to_review"]: - table = _build_overview_table(data["deployed_content"], data["content_to_review"]) - eb.add_field("Over View:", table, inline=False) - - # ── Needs review items ─────────────────────────────────────────────── - if data["content_to_review"]: - lines = [ - _format_item("assignment", name, link) - for name, link in data["content_to_review"] - ] - header = f"Needs review ({len(data['content_to_review'])})" - eb = _add_items_to_builder( - eb, mb, header, lines, author_obj, footer, continuation_title, - ) - - # ── Remaining resources ────────────────────────────────────────────── - remaining = dedupe_remaining_content( - data["deployed_content"], data["content_to_review"] - ) - if remaining: - lines = [ - _format_item(content_type, name, url) - for content_type, name, url in remaining - ] - header = f"Remaining Resources ({len(remaining)})" - eb = _add_items_to_builder( - eb, mb, header, lines, author_obj, footer, continuation_title, - ) - - return Notification( - username=style["username"], - avatar_url=style["avatar_url"], - messages=[mb.build()], - ) -``` - -- [ ] **Step 2: Smoke-test the formatter with the test payload** - -Run: `python -c " -import json -from notifications.formatting.canvas_format import format_notification -from notifications.discord_limits import validate_notification - -with open('tests/test-mdxcanvas-payload.json') as f: - data = json.load(f) - -n = format_notification( - data=data, course_id='110', course_name='CS 110 Course Updates', - course_url='https://byu.instructure.com/courses/20736', - author='robbykap', author_icon='', branch='main', - action_url='https://github.com/actions/runs/1', -) -violations = validate_notification(n) -print(f'Messages: {len(n.messages)}') -for msg in n.messages: - print(f' Embeds: {len(msg.embeds)}') - for emb in msg.embeds: - print(f' Fields: {len(emb.fields)}, Title: {emb.title[:60]}') -print(f'Violations: {violations}') -" -` -Expected: No violations, at least 1 message with fields - -- [ ] **Step 3: Commit** - -```bash -git add notifications/formatting/canvas_format.py -git commit -m "Rewrite canvas_format with tabulate overview and limit-aware builders" -``` - ---- - -### Task 8: Update `send_course.py` and `docker_format.py` - -**Files:** -- Modify: `notifications/send_course.py` -- Modify: `notifications/formatting/docker_format.py` - -- [ ] **Step 1: Update `send_course.py` to pass `cicd_role_id` to formatter** - -In `notifications/send_course.py`, change the `main()` function. The `format_notification` call should pass `cicd_role_id`, and remove the post-hoc content injection: - -Replace lines 32-44: - -```python - notification = format_notification( - data=data, - course_id=course_id, - course_name=course_name, - course_url=course_url, - author=author, - author_icon=author_icon or "", - branch=branch_name, - action_url=action_url, - cicd_role_id=cicd_role_id, - ) - - send_notification(webhook_url, notification) -``` - -(Remove the `if requires_review(data) and cicd_role_id...` block entirely.) - -- [ ] **Step 2: Update `docker_format.py` to use `EmbedBuilder` and accept `cicd_role_id`** - -Replace `notifications/formatting/docker_format.py` with: - -```python -from datetime import datetime, timezone - -from notifications.discord_limits import EmbedBuilder, MessageBuilder -from notifications.formatting.formatting_utils import get_course_style, truncate_error -from notifications.formatting.plain_text_utils import status_color -from notifications.resources import Author, Footer, Notification - - -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, - course_name, - course_url, - author, - author_icon, - branch, - action_url, - cicd_role_id=None, -) -> Notification: - style = get_course_style("docker") - timestamp = datetime.now(timezone.utc).isoformat() - footer = Footer(text=style["footer_text"], icon_url=style["footer_icon_url"]) - author_obj = Author(name=author, icon_url=author_icon) - - # ── Title ──────────────────────────────────────────────────────────── - if data["error"]: - title = f"CS {course_id} | {course_name} -- Build failed" - elif data["failed_images"]: - title = f"CS {course_id} | {course_name} -- Build complete -- failures" - else: - title = f"CS {course_id} | {course_name} -- Build complete" - - # ── Color ──────────────────────────────────────────────────────────── - color = status_color( - has_error=bool(data["error"]), - needs_review=requires_review(data), - ) - - # ── Description ────────────────────────────────────────────────────── - description = f"**Branch:** `{branch}`" - if data["error"]: - truncated = truncate_error(data["error"]) - description += f"\n\n{truncated}" - - # ── Build message ──────────────────────────────────────────────────── - mb = MessageBuilder(color=color, timestamp=timestamp) - eb = mb.new_embed( - title=title, - description=description, - author=author_obj, - footer=footer, - ) - - if data["failed_images"]: - lines = [f"`{image}`" for image in data["failed_images"]] - value = "\n".join(lines) - eb.add_field(f"Failed ({len(data['failed_images'])})", value, inline=False) - - if data["updated_images"]: - lines = [f"`{image}`" for image in data["updated_images"]] - value = "\n".join(lines) - eb.add_field(f"Built ({len(data['updated_images'])})", value, inline=False) - - return Notification( - username=style["username"], - avatar_url=style["avatar_url"], - messages=[mb.build()], - ) -``` - -- [ ] **Step 3: Run all tests except formatter tests (which need updating next)** - -Run: `python -m pytest tests/test_embed_chunking.py tests/test_discord_limits.py tests/test_workflow_wiring.py -v` -Expected: All PASS - -- [ ] **Step 4: Commit** - -```bash -git add notifications/send_course.py notifications/formatting/docker_format.py -git commit -m "Update send_course and docker_format for new builder pattern and cicd_role_id" -``` - ---- - -### Task 9: Update formatter tests - -**Files:** -- Modify: `tests/test_course_text_formatters.py` - -- [ ] **Step 1: Rewrite canvas formatter tests for new format** - -Replace `tests/test_course_text_formatters.py` with: - -```python -from notifications.formatting.canvas_format import format_notification as format_canvas_notification -from notifications.formatting.docker_format import format_notification as format_docker_notification -from notifications.discord_limits import validate_notification - - -class TestCanvasNotificationSuccess: - def test_success_has_overview_table(self): - 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"), - ], - "content_to_review": [], - "error": "", - }, - course_id="235", - course_name="CS 235 Spring 2026", - course_url="https://courses.example/cs235", - author="robbykapua", - author_icon="https://github.com/robbykapua.png", - branch="main", - action_url="https://github.com/actions/runs/123", - ) - message = notification.messages[0] - embed = message.embeds[0] - - assert message.content is None - assert "Deploy complete" in embed.title - assert "`main`" in embed.description - assert embed.author is not None - assert embed.footer is not None - - # First field is overview table - overview = embed.fields[0] - assert "Over View" in overview.name - assert "page" in overview.value - assert "assignment" in overview.value - - # Remaining resources field follows - remaining = embed.fields[1] - assert "Remaining Resources" in remaining.name - assert "Week 12 Overview" in remaining.value - - assert validate_notification(notification) == [] - - -class TestCanvasNotificationReview: - def test_review_has_content_ping_and_review_section(self): - notification = format_canvas_notification( - data={ - "deployed_content": [ - ("page", "Week 12 Overview", "https://courses.example/week-12"), - ("assignment", "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="https://github.com/robbykapua.png", - branch="main", - action_url="https://github.com/actions/runs/123", - cicd_role_id="123456", - ) - message = notification.messages[0] - embed = message.embeds[0] - - assert "<@&123456>" in message.content - assert "review" in embed.title.lower() - - # Has overview, needs review, and remaining fields - field_names = [f.name for f in embed.fields] - assert any("Over View" in n for n in field_names) - assert any("Needs review" in n for n in field_names) - assert any("Remaining Resources" in n for n in field_names) - - # Review items present - review_field = next(f for f in embed.fields if "Needs review" in f.name) - assert "Needs Review" in review_field.value - assert "Professor Approval" in review_field.value - - # Deduplication: review item not in remaining - remaining_field = next(f for f in embed.fields if "Remaining Resources" in f.name) - assert "https://courses.example/review-me" not in remaining_field.value - assert "Week 12 Overview" in remaining_field.value - - assert validate_notification(notification) == [] - - -class TestCanvasNotificationError: - def test_error_has_content_ping_and_error_in_description(self): - 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/actions/runs/123", - cicd_role_id="123456", - ) - message = notification.messages[0] - embed = message.embeds[0] - - assert "failed" in embed.title.lower() - assert "RuntimeError: boom" in embed.description - assert "<@&123456>" in message.content - assert "ERROR" in message.content - assert embed.fields == [] - - assert validate_notification(notification) == [] - - -class TestDockerNotificationFailures: - def test_docker_with_failures(self): - 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/actions/runs/123", - ) - message = notification.messages[0] - embed = message.embeds[0] - - assert message.content is None - assert "failures" in embed.title.lower() - assert len(embed.fields) == 2 - - failed_field = embed.fields[0] - assert "Failed" in failed_field.name - assert "`project-base`" in failed_field.value - - built_field = embed.fields[1] - assert "Built" in built_field.name - assert "`lab-1`" in built_field.value - assert "`lab-2`" in built_field.value - - assert validate_notification(notification) == [] - - -class TestDockerNotificationError: - def test_docker_with_error(self): - 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/actions/runs/123", - ) - message = notification.messages[0] - embed = message.embeds[0] - - assert "failed" in embed.title.lower() - assert "ValueError: bad image" in embed.description - assert message.content is None - - assert validate_notification(notification) == [] -``` - -- [ ] **Step 2: Run formatter tests** - -Run: `python -m pytest tests/test_course_text_formatters.py -v` -Expected: All PASS - -- [ ] **Step 3: Commit** - -```bash -git add tests/test_course_text_formatters.py -git commit -m "Update formatter tests for new canvas/docker format with limit validation" -``` - ---- - -### Task 10: Integration tests with real payload - -**Files:** -- Modify: `tests/test_discord_limits.py` - -- [ ] **Step 1: Add integration tests using the test payload** - -Append to `tests/test_discord_limits.py`: - -```python -import json -from pathlib import Path - -from notifications.formatting.canvas_format import format_notification -from notifications.discord_limits import validate_notification - - -class TestIntegrationWithRealPayload: - @staticmethod - def _load_payload(): - path = Path(__file__).parent / "test-mdxcanvas-payload.json" - with open(path) as f: - return json.load(f) - - def test_success_case_passes_all_limits(self): - data = self._load_payload() - notification = format_notification( - data=data, - course_id="110", - course_name="CS 110 Course Updates", - course_url="https://byu.instructure.com/courses/20736", - author="robbykap", - author_icon="https://github.com/robbykap.png", - branch="main", - action_url="https://github.com/actions/runs/1", - ) - violations = validate_notification(notification) - assert violations == [], f"Limit violations: {violations}" - - def test_review_case_passes_all_limits(self): - data = self._load_payload() - data["content_to_review"] = [ - ("lab-notebook-week1", "https://byu.instructure.com/courses/20736/assignments/1332409"), - ("group-presentation", "https://byu.instructure.com/courses/20736/assignments/1332413"), - ] - notification = format_notification( - data=data, - course_id="110", - course_name="CS 110 Course Updates", - course_url="https://byu.instructure.com/courses/20736", - author="robbykap", - author_icon="https://github.com/robbykap.png", - branch="main", - action_url="https://github.com/actions/runs/1", - cicd_role_id="999888777", - ) - violations = validate_notification(notification) - assert violations == [], f"Limit violations: {violations}" - - def test_error_case_passes_all_limits(self): - data = { - "deployed_content": [], - "content_to_review": [], - "error": "SONDecodeError: Expecting value: line 1 column 1 (char 0)", - } - notification = format_notification( - data=data, - course_id="110", - course_name="CS 110 Course Updates", - course_url="https://byu.instructure.com/courses/20736", - author="robbykap", - author_icon="", - branch="main", - action_url="https://github.com/actions/runs/1", - cicd_role_id="999888777", - ) - violations = validate_notification(notification) - assert violations == [], f"Limit violations: {violations}" - - def test_overview_table_has_correct_type_count(self): - data = self._load_payload() - notification = format_notification( - data=data, - course_id="110", - course_name="CS 110 Course Updates", - course_url="https://byu.instructure.com/courses/20736", - author="robbykap", - author_icon="", - branch="main", - action_url="https://github.com/actions/runs/1", - ) - overview_field = notification.messages[0].embeds[0].fields[0] - # The test payload has these types: module, module_item, assignment, quiz, - # page, quiz_question, quiz_question_order, syllabus, announcement - for resource_type in ["module", "assignment", "quiz", "page"]: - assert resource_type in overview_field.value - - def test_massive_payload_passes_all_limits(self): - data = { - "deployed_content": [ - ("page", f"page-{i}", f"https://example.com/pages/{i}") - for i in range(500) - ], - "content_to_review": [], - "error": "", - } - notification = format_notification( - data=data, - course_id="110", - course_name="CS 110 Course Updates", - course_url="https://byu.instructure.com/courses/20736", - author="robbykap", - author_icon="", - branch="main", - action_url="https://github.com/actions/runs/1", - ) - violations = validate_notification(notification) - assert violations == [], f"Limit violations: {violations}" - - def test_empty_payload_has_no_content(self): - from notifications.formatting.canvas_format import has_content - data = { - "deployed_content": [], - "content_to_review": [], - "error": "", - } - assert has_content(data) is False -``` - -- [ ] **Step 2: Run integration tests** - -Run: `python -m pytest tests/test_discord_limits.py::TestIntegrationWithRealPayload -v` -Expected: All PASS - -- [ ] **Step 3: Commit** - -```bash -git add tests/test_discord_limits.py -git commit -m "Add integration tests with real payload and edge cases" -``` - ---- - -### Task 11: Full test suite verification - -- [ ] **Step 1: Run all tests** - -Run: `python -m pytest tests/ -v` -Expected: All tests PASS with no collection errors - -- [ ] **Step 2: Final commit if any fixups needed** - -```bash -git add -A -git commit -m "Fix any remaining test issues" -``` diff --git a/docs/superpowers/specs/2026-04-08-canvas-notification-redesign-design.md b/docs/superpowers/specs/2026-04-08-canvas-notification-redesign-design.md deleted file mode 100644 index 504be2d..0000000 --- a/docs/superpowers/specs/2026-04-08-canvas-notification-redesign-design.md +++ /dev/null @@ -1,159 +0,0 @@ -# Canvas Notification Redesign - -## Overview - -Redesign the Canvas deploy notification system to produce cleaner Discord messages with tabulate-generated overview tables, aligned resource listings, and limit-aware formatting that prevents Discord API errors. - -## Decisions - -- **Approach:** Limit-aware formatting (Approach B) — formatters build content incrementally, tracking character budget via builder classes -- **Resource listings:** Plain markdown with alignment, keeping links clickable—wrap resource types in backticks for fixed-width font -- **Item display:** Every item listed fully, including quiz_question and quiz_question_order -- **Overview table counts:** Deduplicated union across both `deployed_content` and `content_to_review` -- **Content message:** Built inside `canvas_format.py` (formatter owns full message shape), role ID passed in -- **Refactor:** Extract limits/chunking into `discord_limits.py`, keep `send_notification.py` as webhook sender name - -## Architecture - -### New module: `notifications/discord_limits.py` - -Constants: - -``` -TITLE_LIMIT = 256 -DESCRIPTION_LIMIT = 4096 -FIELD_NAME_LIMIT = 256 -FIELD_VALUE_LIMIT = 1024 -FIELDS_PER_EMBED = 25 -FOOTER_LIMIT = 2048 -AUTHOR_NAME_LIMIT = 256 -EMBED_CHAR_LIMIT = 6000 -MAX_EMBEDS_PER_MESSAGE = 10 -CONTENT_LIMIT = 2000 -``` - -Classes: - -- **`EmbedBuilder`** — stateful builder tracking character budget - - `__init__(title, description, color, timestamp, author, footer)` — sets base metadata, calculates initial char usage - - `remaining_chars() -> int` - - `can_add_field(name, value) -> bool` — checks char limit and 25-field limit - - `add_field(name, value, inline) -> bool` — adds if fits, returns False otherwise - - `build() -> Embed` - -- **`MessageBuilder`** — manages multiple embeds per message - - Tracks embed count against `MAX_EMBEDS_PER_MESSAGE` - - `new_embed(...)` — starts new embed when current fills up - - `build() -> WebhookMessage` - -Functions: - -- `calc_embed_size(embed: Embed) -> int` — moved from `send_notification.py` -- `split_content(content: str, max_chars: int) -> list[str]` — moved from `send_notification.py` -- `validate_notification(notification: Notification) -> list[str]` — returns limit violations - -### Refactored: `notifications/send_notification.py` - -Keeps only webhook-sending logic: -- `_build_discord_embed(embed_data)` — converts `Embed` dataclass to `DiscordEmbed` -- `_execute_webhook(webhook_url, notification, content, embeds)` — sends via `discord_webhook` -- `send_notification(webhook_url, notification)` — iterates messages, splits content if needed via `split_content()`, sends - -Removed: all constants, `_calc_embed_size`, `_chunk_embed`, `_build_chunk`, `_split_content`. - -### Rewritten: `notifications/formatting/canvas_format.py` - -Three notification cases: - -**Case 1: Error (red)** -- Content: `@CICD ERROR—MDXCanvas failed to deploy. View [here]({action_url})` -- Embed: title, branch in description, error in code block. No fields, no table. - -**Case 2: Needs Review (yellow)** -- Content: `<@&{role_id}>—Deployed Resources to Review` -- Embed fields: - 1. Overview table (tabulate `pipe` format in code block) — resource type x count - 2. Needs Review items — `assignment\` [name](link)` per line - 3. Remaining Resources — `type\` [name](link)` per line - -**Case 3: Success (green)** -- Content: None -- Embed fields: - 1. Overview table - 2. Remaining Resources - -Overview table built with `tabulate(rows, headers=["Resource Type", "Count"], tablefmt="pipe")` wrapped in a code block. Count is from deduplicated union of both lists. - -Resource lines packed into field values using `EmbedBuilder.can_add_field()`. When a field fills, a new field or embed is started. - -`format_notification()` signature adds `cicd_role_id` parameter so the formatter can build the content message. - -### Modified: `notifications/formatting/formatting_utils.py` - -- Remove `chunk_field_lines()` (replaced by `EmbedBuilder`) -- Keep `get_course_style()`, `truncate_error()` - -### Modified: `notifications/formatting/docker_format.py` - -- Replace `chunk_field_lines` usage with `EmbedBuilder` for consistency with canvas_format - -### Modified: `notifications/send_course.py` - -- Pass `cicd_role_id` into `format_notification()` instead of applying it after the fact -- Remove post-hoc content message injection - -### Dependency - -NO PROJECT DEPENDENCIES — this is not a pypi package or anything similar just an internal module - -## Test Strategy - -### New: `tests/test_discord_limits.py` - -**Limit validation tests** — verify every generated component respects Discord limits: -- Title ≤ 256, Description ≤ 4096, Field name ≤ 256, Field value ≤ 1024 -- Fields per embed ≤ 25, Embed total chars ≤ 6000 -- Embeds per message ≤ 10, Content ≤ 2000 - -**Integration tests** using `test-mdxcanvas-payload.json`: -- Load payload, run `format_notification()` for all 3 cases -- Run `validate_notification()` — assert zero violations -- Verify overview table row count matches distinct resource type count -- Verify all `content_to_review` items appear in needs-review fields -- Verify no item in both needs-review and remaining sections - -**EmbedBuilder unit tests:** -- Refuses field when char budget exhausted -- Refuses field when 25-field limit hit -- `remaining_chars()` decreases correctly -- `build()` produces valid `Embed` - -**Edge cases:** -- Empty payload — `has_content()` returns False -- Error-only payload -- Massive payload (500+ items) — still valid notifications - -### Modified: `tests/test_embed_chunking.py` - -- Update imports from `discord_limits` -- Replace `_chunk_embed` tests with `EmbedBuilder` tests - -## File Change Summary - -| File | Action | -|---|---| -| `notifications/discord_limits.py` | New | -| `notifications/send_notification.py` | Modify — remove constants/chunking | -| `notifications/formatting/canvas_format.py` | Rewrite | -| `notifications/formatting/formatting_utils.py` | Modify — remove `chunk_field_lines` | -| `notifications/formatting/docker_format.py` | Modify — update imports | -| `notifications/send_course.py` | Modify — pass role ID to formatter | -| `tests/test_discord_limits.py` | New | -| `tests/test_embed_chunking.py` | Modify — update imports/tests | - -## Not Changed - -- `notifications/resources.py` — dataclasses unchanged -- `notifications/formatting/plain_text_utils.py` — `status_color()` and `dedupe_remaining_content()` unchanged - -## DO NOT ADD EMOJIS — we want to keep the tone professional and clear, especially for error notifications. \ No newline at end of file diff --git a/notifications/formatting/canvas_format.py b/notifications/formatting/canvas_format.py index 1f67742..32886a2 100644 --- a/notifications/formatting/canvas_format.py +++ b/notifications/formatting/canvas_format.py @@ -169,7 +169,7 @@ def format_notification( # -- Overview table ------------------------------------------------------- if data["deployed_content"] or data["content_to_review"]: table = _build_overview_table(data["deployed_content"], data["content_to_review"]) - eb.add_field("Over View:", table, inline=False) + eb.add_field("Overview:", table, inline=False) # -- Needs review items --------------------------------------------------- if data["content_to_review"]: diff --git a/tests/send_test_notification.py b/tests/send_test_notification.py index 683d5ab..f8516d1 100644 --- a/tests/send_test_notification.py +++ b/tests/send_test_notification.py @@ -36,5 +36,5 @@ author_icon="https://github.com/ghost.png", branch_name="test-branch", action_url="https://github.com/actions/runs/0", - cicd_role_id=None, + cicd_role_id=1373077186072019024, ) diff --git a/tests/test_course_text_formatters.py b/tests/test_course_text_formatters.py index 2ce3abf..c370e09 100644 --- a/tests/test_course_text_formatters.py +++ b/tests/test_course_text_formatters.py @@ -34,7 +34,7 @@ def test_success_has_overview_table(self): # First field is overview table overview = embed.fields[0] - assert "Over View" in overview.name + assert "Overview" in overview.name assert "page" in overview.value assert "assignment" in overview.value @@ -77,7 +77,7 @@ def test_review_has_content_ping_and_review_section(self): # Has overview, needs review, and remaining fields field_names = [f.name for f in embed.fields] - assert any("Over View" in n for n in field_names) + assert any("Overview" in n for n in field_names) assert any("Needs review" in n for n in field_names) assert any("Remaining Resources" in n for n in field_names)