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/workflows/poetry_publish.yaml b/.github/workflows/poetry_publish.yaml index 47a34e8..e19067c 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,24 @@ 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 }}" + - 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 ${{ inputs.extra-packages }} - 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: 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 }} diff --git a/notifications/formatting/formatting_utils.py b/notifications/formatting/formatting_utils.py index b9196de..51dde0f 100644 --- a/notifications/formatting/formatting_utils.py +++ b/notifications/formatting/formatting_utils.py @@ -22,6 +22,14 @@ 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 new file mode 100644 index 0000000..e5a68ef --- /dev/null +++ b/notifications/formatting/pypi_format.py @@ -0,0 +1,33 @@ +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 a3d983d..bdd5d0d 100644 --- a/notifications/formatting/style.md +++ b/notifications/formatting/style.md @@ -4,3 +4,11 @@ |--------|--------------------------|------------------------------|-----------|-------------------------------------|-------------------------|------------------------------| | 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 new file mode 100644 index 0000000..d8d5fb4 --- /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 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 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_embed_chunking.py b/tests/test_embed_chunking.py index 733383d..8bf18c9 100644 --- a/tests/test_embed_chunking.py +++ b/tests/test_embed_chunking.py @@ -83,3 +83,18 @@ 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