diff --git a/.github/actions/send-course-notification/action.yml b/.github/actions/send-course-notification/action.yml new file mode 100644 index 0000000..9f213ad --- /dev/null +++ b/.github/actions/send-course-notification/action.yml @@ -0,0 +1,63 @@ +name: Send Course Notification +description: Create fallback output if needed 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 + course-name: + description: "Course name" + required: true + course-url: + description: "Course URL" + 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: 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 }}" \ + --course-name "${{ inputs.course-name }}" \ + --course-url "${{ inputs.course-url }}" \ + --author "${{ github.actor }}" \ + --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/actions/send-pypi-notification/action.yml b/.github/actions/send-pypi-notification/action.yml new file mode 100644 index 0000000..7472957 --- /dev/null +++ b/.github/actions/send-pypi-notification/action.yml @@ -0,0 +1,62 @@ +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 + old-version: + description: "Previous PyPI version (for version transition display)" + required: false + default: "" + +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 // empty') + 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 }}'" + + if [ -n "${{ inputs.old-version }}" ]; then + CMD="$CMD --old-version '${{ inputs.old-version }}'" + fi + + echo "$CMD" + eval "$CMD" diff --git a/.github/workflows/check_toml.yaml b/.github/workflows/check_toml.yaml index e284d3c..d19014f 100644 --- a/.github/workflows/check_toml.yaml +++ b/.github/workflows/check_toml.yaml @@ -13,6 +13,9 @@ on: version: description: "The version from pyproject.toml" value: ${{ jobs.check.outputs.version }} + pypi_version: + description: "The current version on PyPI" + value: ${{ jobs.check.outputs.pypi_version }} jobs: check: @@ -20,6 +23,7 @@ jobs: outputs: uped_toml: ${{ steps.extract_update.outputs.uped_toml }} version: ${{ steps.get_build_version.outputs.version }} + pypi_version: ${{ steps.get_pypi_version.outputs.pypi_version }} steps: - name: Checkout repo uses: actions/checkout@v4 @@ -36,8 +40,9 @@ jobs: - name: Get version from PyPI id: get_pypi_version run: | - VERSION=$(curl -s https://pypi.org/pypi/${{ inputs.pypi_package }}/json | jq -r .info.version) - echo "pypi_version=$VERSION" >> $GITHUB_ENV + VERSION=$(curl -s https://pypi.org/pypi/${{ inputs.pypi_package }}/json | jq -r '.info.version // empty') + echo "pypi_version=${VERSION:-}" >> $GITHUB_ENV + echo "pypi_version=${VERSION:-}" >> $GITHUB_OUTPUT - name: Install Poetry and Version Plugin run: pip install "poetry<2" poetry-version-from-file @@ -57,5 +62,8 @@ jobs: - name: Check for version update id: extract_update run: | - python3 utils/.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}" - echo "uped_toml=true" >> $GITHUB_OUTPUT + if python3 utils/.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}"; then + echo "uped_toml=true" >> $GITHUB_OUTPUT + else + echo "uped_toml=false" >> $GITHUB_OUTPUT + fi diff --git a/.github/workflows/docker_automation.yaml b/.github/workflows/docker_automation.yaml index 0a57452..1e28758 100644 --- a/.github/workflows/docker_automation.yaml +++ b/.github/workflows/docker_automation.yaml @@ -6,6 +6,16 @@ on: course_id: required: true type: string + course_name: + required: true + type: string + course_url: + required: true + type: string + utils_ref: + required: false + default: main + type: string secrets: discord_role: required: true @@ -35,8 +45,13 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils + ref: ${{ inputs.utils_ref }} path: utils + - name: Install notification dependencies + shell: bash + run: pip install discord-webhook markdowndata + - name: Get changed files run: | files=$(git diff --name-only ${{ github.event.before }} ${{ github.event.after }}) @@ -72,35 +87,17 @@ 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: ./utils/.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 }} + 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 f354e2a..c727f8f 100644 --- a/.github/workflows/mdxcanvas_automation.yaml +++ b/.github/workflows/mdxcanvas_automation.yaml @@ -6,6 +6,16 @@ on: course_id: required: true type: string + course_name: + required: true + type: string + course_url: + required: true + type: string + utils_ref: + required: false + default: main + type: string mdxcanvas_version: required: true type: string @@ -43,7 +53,7 @@ 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 @@ -53,12 +63,18 @@ jobs: uses: actions/checkout@v4 with: repository: BYU-CS-Course-Ops/utils + ref: ${{ inputs.utils_ref }} path: utils - - name: Setup - run: | - mkdir -p "$LOGS_DIR" - pip install mdxcanvas==${{ inputs.mdxcanvas_version }} discord-webhook + - name: Install MDXCanvas + run: pip install mdxcanvas==${{ inputs.mdxcanvas_version }} + + - name: Install notification dependencies + shell: bash + run: pip install discord-webhook markdowndata + + - name: Create logs directory + run: mkdir -p "$LOGS_DIR" - name: Run MDXCanvas id: mdxcanvas @@ -80,36 +96,24 @@ 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: ./utils/.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 }} + 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/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 47a34e8..de37bbf 100644 --- a/.github/workflows/poetry_publish.yaml +++ b/.github/workflows/poetry_publish.yaml @@ -7,31 +7,41 @@ on: required: true type: string secrets: - pypi_user: - required: true - pypi_password: - required: true discord_webhook_url: required: true discord_role: required: true jobs: - check-toml-version: - uses: ./.github/workflows/check_toml.yaml - with: - pypi_package: ${{ inputs.pypi_package }} - secrets: inherit - poetry-publish: - needs: check-toml-version runs-on: ubuntu-latest + permissions: + contents: read + id-token: write outputs: - success: ${{ steps.publish.outputs.success }} - version: ${{ needs.check-toml-version.outputs.version }} + success: ${{ steps.result.outputs.success }} + version: ${{ steps.get_build_version.outputs.version }} + uped_toml: ${{ steps.extract_update.outputs.uped_toml }} + pypi_version: ${{ steps.get_pypi_version.outputs.pypi_version }} steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils + path: utils + + - name: Get version from PyPI + id: get_pypi_version + run: | + VERSION=$(curl -s https://pypi.org/pypi/${{ inputs.pypi_package }}/json | jq -r '.info.version // empty') + echo "pypi_version=${VERSION:-}" >> $GITHUB_ENV + echo "pypi_version=${VERSION:-}" >> $GITHUB_OUTPUT - name: Install Poetry and Version Plugin run: pip install "poetry<2" poetry-version-from-file @@ -42,59 +52,72 @@ jobs: - name: Build PyPI package run: poetry build - - name: Publish PyPI package - id: publish - env: - POETRY_PYPI_USERNAME: ${{ secrets.pypi_user }} - POETRY_PYPI_PASSWORD: ${{ secrets.pypi_password }} + - name: Get version from poetry build + id: get_build_version + run: | + VERSION=$(poetry version -s) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Check for version update + id: extract_update + run: | + if python3 utils/.github/scripts/check_version.py "${{ steps.get_build_version.outputs.version }}" "${{ env.pypi_version }}"; then + echo "uped_toml=true" >> $GITHUB_OUTPUT + else + echo "uped_toml=false" >> $GITHUB_OUTPUT + fi + + - name: Check version was bumped + id: check run: | - if [ "${{ needs.check-toml-version.outputs.uped_toml }}" != "true" ]; then + if [ "${{ steps.extract_update.outputs.uped_toml }}" != "true" ]; then echo "pyproject.toml version was not updated — skipping publish" - echo "success=false" >> $GITHUB_OUTPUT - exit 1 + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT fi - if poetry publish --username "$POETRY_PYPI_USERNAME" --password "$POETRY_PYPI_PASSWORD"; then - echo "Publish succeeded" + - name: Publish to PyPI via trusted publisher + id: publish + if: steps.check.outputs.skip != 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + print-hash: true + + - name: Set publish result + id: result + if: always() + run: | + if [ "${{ steps.check.outputs.skip }}" == "true" ]; then + echo "success=false" >> $GITHUB_OUTPUT + elif [ "${{ steps.publish.outcome }}" == "success" ]; then echo "success=true" >> $GITHUB_OUTPUT else - echo "Publish failed" echo "success=false" >> $GITHUB_OUTPUT - exit 1 fi notify-discord: - needs: [check-toml-version, poetry-publish] + needs: 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 }}" + - name: Checkout utils repo + uses: actions/checkout@v4 + with: + repository: BYU-CS-Course-Ops/utils + path: utils - echo "$CMD" + - name: Install notification dependencies + shell: bash + run: pip install discord-webhook markdowndata - 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: BYU-CS-Course-Ops/utils/.github/actions/send-pypi-notification@main + with: + pypi-package: ${{ inputs.pypi_package }} + success: ${{ needs.poetry-publish.outputs.success }} + toml-updated: ${{ needs.poetry-publish.outputs.uped_toml }} + version: ${{ needs.poetry-publish.outputs.version }} + discord-role: ${{ secrets.discord_role }} + discord-webhook-url: ${{ secrets.discord_webhook_url }} + old-version: ${{ needs.poetry-publish.outputs.pypi_version }} 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 diff --git a/README.md b/README.md index b1fb779..ae8f12f 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,19 @@ on: push: branches: [main] -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 }} - docker_password: ${{ secrets.DOCKER_PASSWORD }} - discord_webhook_url: ${{ secrets.GHA_235_DISCORD_WEBHOOK }} -``` +jobs: + docker_automation: + 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 }} +``` ## Example usage for mdxcanvas_automation.yaml @@ -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: @@ -66,6 +70,10 @@ jobs: ## Example usage for poetry_publish.yaml +> **Important:** This workflow uses PyPI OIDC trusted publishers instead of API tokens. +> The calling workflow **must** include `permissions: id-token: write` or publishing +> will fail with a `startup_failure` error and no logs. + ```yaml name: MDXCanvas Publish @@ -74,14 +82,46 @@ on: push: branches: [main] +permissions: + id-token: write + jobs: mdxcanvas_publish: uses: BYU-CS-Course-Ops/utils/.github/workflows/poetry_publish.yaml@main with: pypi_package: "mdxcanvas" secrets: - pypi_user: ${{ secrets.PYPI_USER }} - pypi_password: ${{ secrets.PYPI_PASSWORD }} discord_webhook_url: ${{ secrets.GHA_BEANLAB_DISCORD_WEBHOOK }} discord_role: ${{ secrets.CICD_NOTIFY_DISCORD_ROLE }} ``` + +## Migrating poetry_publish.yaml to OIDC + +The `poetry_publish.yaml` workflow now uses [PyPI trusted publishers](https://docs.pypi.org/trusted-publishers/) (OIDC) instead of username/password API tokens. + +### Steps to migrate + +1. **Configure a trusted publisher on PyPI** + - Go to your package on [pypi.org](https://pypi.org) > Manage > Publishing + - Add a new "GitHub Actions" publisher: + - **Owner:** your GitHub org (e.g., `BYU-CS-Course-Ops`) + - **Repository:** the repo that calls the workflow + - **Workflow name:** the filename of your *calling* workflow (e.g., `publish.yaml`), **not** `poetry_publish.yaml` + - **Environment:** leave blank + +2. **Add `permissions: id-token: write` to your calling workflow** + - This must be at the **workflow level**, not the job level + - Without this, GitHub Actions returns a `startup_failure` with no logs + +3. **Remove old secrets** + - Delete the `pypi_user` and `pypi_password` lines from your workflow's `secrets:` block + - Optionally remove the `PYPI_USER` and `PYPI_PASSWORD` repository secrets from GitHub Settings + +4. **Keep Discord secrets** — `discord_webhook_url` and `discord_role` are still required + +## Post-merge cleanup + +After merging PR #7, update the testing repo (`testkapua/testing-repo`) workflow refs from `@claude/wonderful-lovelace` to `@main`: +- `.github/workflows/mdxcanvas_automation.yaml` +- `.github/workflows/docker_automation.yaml` +- `.github/workflows/poetry_publish.yaml` diff --git a/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..5f8e596 --- /dev/null +++ b/notifications/formatting/canvas_format.py @@ -0,0 +1,110 @@ +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 + + +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_canvas_summary(data: dict) -> tuple[str, str | None]: + 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." + ) + 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: + return summary, f"> Updated content: {changed_items}" + return summary, None + + +def format_notification( + data, + course_id, + course_name, + course_url, + author, + author_icon, + branch, + action_url, +) -> Notification: + style = get_course_style("canvas") + + 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) + + 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"], + messages=[ + WebhookMessage( + 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"], + 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 new file mode 100644 index 0000000..b5f6b99 --- /dev/null +++ b/notifications/formatting/docker_format.py @@ -0,0 +1,99 @@ +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 + + +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 _build_docker_summary(data: dict) -> tuple[str, str | None]: + 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." + ) + else: + summary = f"**Status:** {updated_count} {pluralize(updated_count, 'image')} updated." + + if error_count: + summary += f" {error_count} {pluralize(error_count, 'error')} reported." + + 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( + data, + course_id, + course_name, + course_url, + author, + author_icon, + branch, + action_url, +) -> 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) + + return Notification( + username=style["username"], + avatar_url=style["avatar_url"], + messages=[ + WebhookMessage( + 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"], + 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/formatting_utils.py b/notifications/formatting/formatting_utils.py new file mode 100644 index 0000000..30fdea3 --- /dev/null +++ b/notifications/formatting/formatting_utils.py @@ -0,0 +1,145 @@ +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 "\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 build_resource_summary(deployed_content: list) -> dict[str, int]: + """Count deployed items by resource type from deployed_content tuples.""" + counts: dict[str, int] = {} + for rtype, _name, _link in deployed_content: + label = rtype.title() + counts[label] = counts.get(label, 0) + 1 + return counts + + +def resource_count_fields(counts: dict[str, int]) -> list[Field]: + """Produce inline Discord fields for resource type counts (3 per row).""" + return [ + Field(name=rtype, value=f"**{count}**", inline=True) + for rtype, count in counts.items() + ] + + +def course_info_field(course_name: str | None, course_url: str | None) -> Field | None: + """Create a clickable course link field. Returns None if data is missing.""" + if course_name and course_url: + return Field( + name="**Course:**", + value=f"[{course_name}]({course_url})", + inline=False, + ) + elif course_name: + return Field(name="**Course:**", value=course_name, inline=False) + return None + + +def truncate_error(error: str, max_chars: int = 900) -> str: + if not error: + return "```\nNo error output available.\n```" + + 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/plain_text_utils.py b/notifications/formatting/plain_text_utils.py new file mode 100644 index 0000000..e5ee5da --- /dev/null +++ b/notifications/formatting/plain_text_utils.py @@ -0,0 +1,153 @@ +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 + 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_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]], +) -> 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(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 new file mode 100644 index 0000000..a4a6ad1 --- /dev/null +++ b/notifications/formatting/pypi_format.py @@ -0,0 +1,68 @@ +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 + + +def format_notification( + ntype, + author, + author_icon, + action_url, + success, + version, + old_version=None, +) -> Notification: + style = get_pypi_style(ntype) + pypi_name = style.get("pypi_name", ntype) + + if success: + if old_version: + description = f"**`{old_version}`** \u2192 **`{version}`**" + else: + description = f"Updated to version **`{version}`**" + else: + description = f"An **error occurred** while updating {style['display_name']}." + + fields: list[Field] = [spacer()] + + if success and version: + fields.append( + Field( + name="**PyPI Package:**", + value=f"[{pypi_name} v{version}](https://pypi.org/project/{pypi_name}/{version}/)", + inline=False, + ) + ) + fields.append(spacer()) + + fields.append( + Field( + name="**GitHub Action:**", + value=f"[View Here]({action_url})", + inline=False, + ) + ) + fields.append(spacer()) + + return Notification( + username=style["username"], + 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=fields, + ) + ], + ) + ], + ) diff --git a/notifications/formatting/style.md b/notifications/formatting/style.md new file mode 100644 index 0000000..8e9c603 --- /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 | pypi_name | username | display_name | title | hex_color | footer_text | footer_icon_url | +| ---------------- | ---------------- | ------------------------------ | ---------------- | ----------------------- | --------- | ------------------------------ | ---------------------------- | +| mdxcanvas | mdxcanvas | MDXCanvas Notifications | MDXCanvas | MDXCanvas Update | #F56236 | MDXCanvas GitHub Action | https://tinyurl.com/4ky2afzx | +| markdowndata | markdowndata | MarkdownData Notifications | MarkdownData | MarkdownData Update | #13DC56 | MarkdownData GitHub Action | https://tinyurl.com/4ky2afzx | +| byu_pytest_utils | byu-pytest-utils | BYU Pytest Utils Notifications | BYU Pytest Utils | BYU Pytest Utils Update | #3498DB | BYU Pytest Utils GitHub Action | https://tinyurl.com/4ky2afzx | diff --git a/notifications/resources.py b/notifications/resources.py new file mode 100644 index 0000000..80e6a9f --- /dev/null +++ b/notifications/resources.py @@ -0,0 +1,46 @@ +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 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 + messages: list[WebhookMessage] + avatar_url: str | None = None diff --git a/notifications/send_course.py b/notifications/send_course.py new file mode 100644 index 0000000..11a15d0 --- /dev/null +++ b/notifications/send_course.py @@ -0,0 +1,73 @@ +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, 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.") + + 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, + course_name=course_name, + course_url=course_url, + author=author, + author_icon="", + branch=branch_name, + action_url=action_url, + ) + + if requires_review(data) 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 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("--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("--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.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 new file mode 100644 index 0000000..6b74d29 --- /dev/null +++ b/notifications/send_notification.py @@ -0,0 +1,268 @@ +from discord_webhook import DiscordWebhook, DiscordEmbed + +from .resources import Embed, Notification, WebhookMessage + +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, + ) + 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_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, + ) + + 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 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, + 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): + 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.") diff --git a/notifications/send_pypi.py b/notifications/send_pypi.py new file mode 100644 index 0000000..83fd3c2 --- /dev/null +++ b/notifications/send_pypi.py @@ -0,0 +1,45 @@ +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, old_version=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, + old_version=old_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") + parser.add_argument("--old-version", default=None, help="Previous PyPI version for transition display") + + args = parser.parse_args() + + main(args.type, args.author, args.author_icon, args.action_url, args.success, args.version, + args.cicd_id, args.old_version) 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_course_text_formatters.py b/tests/test_course_text_formatters.py new file mode 100644 index 0000000..1521d8b --- /dev/null +++ b/tests/test_course_text_formatters.py @@ -0,0 +1,152 @@ +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) + 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" in text + assert "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", + ) + + message = notification.messages[0] + text = "\n\n".join(message.sections) + + 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(): + 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", + ) + + 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 + + +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", + ) + + 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 diff --git a/tests/test_embed_chunking.py b/tests/test_embed_chunking.py new file mode 100644 index 0000000..85e7806 --- /dev/null +++ b/tests/test_embed_chunking.py @@ -0,0 +1,201 @@ +from notifications.resources import Author, Embed, Field, Footer +from notifications.send_notification import MAX_EMBED_CHARS, _calc_embed_size, _chunk_embed + + +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 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( + 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): + 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 diff --git a/tests/test_plain_text_chunking.py b/tests/test_plain_text_chunking.py new file mode 100644 index 0000000..955c24e --- /dev/null +++ b/tests/test_plain_text_chunking.py @@ -0,0 +1,114 @@ +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" diff --git a/tests/test_workflow_wiring.py b/tests/test_workflow_wiring.py new file mode 100644 index 0000000..31f41bf --- /dev/null +++ b/tests/test_workflow_wiring.py @@ -0,0 +1,48 @@ +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 + + +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 "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 + + +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