diff --git a/.github/workflows/create_todo_issue.yml b/.github/workflows/create_todo_issue.yml new file mode 100644 index 000000000000..99b294bad860 --- /dev/null +++ b/.github/workflows/create_todo_issue.yml @@ -0,0 +1,106 @@ +#/ +# @license Apache-2.0 +# +# Copyright (c) 2024 The Stdlib Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +#/ + +# Workflow name: +name: create_todo_issue + +# Workflow triggers: +on: + # Allow the workflow to be triggered by other workflows: + workflow_call: + # Define the input parameters for the workflow: + inputs: + source_type: + description: 'Source context type: ''pr'' (pull request comment) or ''commit'' (commit comment)' + required: true + type: string + source_ref: + description: 'Pull request number when source_type is ''pr''; commit SHA when source_type is ''commit''' + required: true + type: string + comment_body: + description: 'Body of the slash command comment' + required: true + type: string + user: + description: 'GitHub login of the commenter' + required: true + type: string + + # Define the secrets accessible by the workflow: + secrets: + STDLIB_BOT_GITHUB_TOKEN: + description: 'stdlib-bot GitHub token with permission to create issues and comments' + required: true + + # Allow the workflow to be manually triggered: + workflow_dispatch: + inputs: + source_type: + description: 'Source context type: ''pr'' (pull request comment) or ''commit'' (commit comment)' + required: true + type: string + source_ref: + description: 'Pull request number when source_type is ''pr''; commit SHA when source_type is ''commit''' + required: true + type: string + comment_body: + description: 'Body of the slash command comment' + required: true + type: string + user: + description: 'GitHub login of the commenter' + required: true + type: string + +# Workflow jobs: +jobs: + + # Define a job for creating a new todo issue: + create_todo_issue: + + # Define a display name: + name: 'Create a new todo issue' + + # Define the type of virtual host machine: + runs-on: ubuntu-latest + + # Define the job's steps: + steps: + # Checkout the repository: + - name: 'Checkout repository' + # Pin action to full length commit SHA + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Ensure we have access to the scripts directory: + sparse-checkout: | + .github/workflows/scripts + sparse-checkout-cone-mode: false + timeout-minutes: 10 + + # Create a new todo issue: + - name: 'Create a new todo issue' + env: + GITHUB_TOKEN: ${{ secrets.STDLIB_BOT_GITHUB_TOKEN || secrets.STDLIB_BOT_PAT_REPO_WRITE }} + COMMENT_BODY: ${{ inputs.comment_body }} + COMMENTER: ${{ inputs.user }} + SOURCE_TYPE: ${{ inputs.source_type }} + SOURCE_REF: ${{ inputs.source_ref }} + run: | + . "$GITHUB_WORKSPACE/.github/workflows/scripts/create_todo_issue/run" + timeout-minutes: 10 diff --git a/.github/workflows/scripts/create_todo_issue/run b/.github/workflows/scripts/create_todo_issue/run new file mode 100755 index 000000000000..b637c7778046 --- /dev/null +++ b/.github/workflows/scripts/create_todo_issue/run @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +# +# @license Apache-2.0 +# +# Copyright (c) 2024 The Stdlib Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Script to create a new todo issue from a /stdlib todo slash command. +# +# Environment variables: +# +# GITHUB_TOKEN GitHub token with permission to create issues and comments. +# COMMENT_BODY Body of the /stdlib todo slash command comment. +# COMMENTER GitHub login of the commenter. +# SOURCE_TYPE Source context type: 'pr' or 'commit'. +# SOURCE_REF Pull request number when SOURCE_TYPE is 'pr'; commit SHA when SOURCE_TYPE is 'commit'. + +# Ensure that the exit status of pipelines is non-zero in the event that at least one of the commands in a pipeline fails: +set -o pipefail + + +# VARIABLES # + +# GitHub API base URL: +github_api_url="https://api.github.com" + +# Repository owner and name: +repo_owner="stdlib-js" +repo_name="stdlib" + +# Convenience variable for a backtick character (avoids quoting issues in strings): +bt='`' + + +# FUNCTIONS # + +# Error handler. +# +# $1 - error status +on_error() { + echo 'ERROR: An error was encountered during execution.' >&2 + exit "$1" +} + +# Prints a success message. +print_success() { + echo 'Success!' >&2 +} + +# Posts a comment on the source context (pull request or commit). +# +# $1 - comment body (plain text; will be JSON-encoded) +post_source_comment() { + local body="$1" + local payload + local endpoint + payload="{\"body\":$(printf '%s' "${body}" | jq -R -s -c .)}" + if [ "${SOURCE_TYPE}" = "pr" ]; then + endpoint="${github_api_url}/repos/${repo_owner}/${repo_name}/issues/${SOURCE_REF}/comments" + else + endpoint="${github_api_url}/repos/${repo_owner}/${repo_name}/commits/${SOURCE_REF}/comments" + fi + curl -s -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "Content-Type: application/json" \ + -d "${payload}" \ + "${endpoint}" \ + > /dev/null +} + + +# MAIN # + +main() { + local tmp_dir + local tmp_comment + local tmp_attrs + local tmp_content + local quote + local attrs + local stdlib_target + local labels_raw + local labels_json + local content + local first_line + local title + local body_raw + local body + local target_owner + local target_repo + local membership_status + local provenance + local issue_body + local issue_payload + local issue_response + local issue_url + local requested_label + local dropped_labels + local dropped_list + local label + + # Validate required inputs: + if [ -z "${SOURCE_TYPE}" ]; then + echo "ERROR: SOURCE_TYPE is required." >&2 + on_error 1 + fi + if [ "${SOURCE_TYPE}" != "pr" ] && [ "${SOURCE_TYPE}" != "commit" ]; then + echo "ERROR: SOURCE_TYPE must be 'pr' or 'commit'." >&2 + on_error 1 + fi + if [ -z "${SOURCE_REF}" ]; then + echo "ERROR: SOURCE_REF is required." >&2 + on_error 1 + fi + if [ -z "${COMMENTER}" ]; then + echo "ERROR: COMMENTER is required." >&2 + on_error 1 + fi + if [ -z "${COMMENT_BODY}" ]; then + echo "ERROR: COMMENT_BODY is required." >&2 + on_error 1 + fi + + # Create a temporary working directory: + tmp_dir=$(mktemp -d) + tmp_comment="${tmp_dir}/comment.txt" + tmp_attrs="${tmp_dir}/attrs.txt" + tmp_content="${tmp_dir}/content.txt" + + # Write the comment body to a file to safely handle multi-line content: + printf '%s' "${COMMENT_BODY}" > "${tmp_comment}" + + # Build a Markdown quote of the comment body for use in error replies: + quote=$(sed 's/^/> /' "${tmp_comment}") + + # Use awk to extract the fenced code block and its attributes. + # Expected format: ```text {attrs}\n```: + awk -v attrs_file="${tmp_attrs}" -v content_file="${tmp_content}" ' + BEGIN { in_block = 0; first_line = 1 } + !in_block && /^```text[[:space:]]*\{/ { + match($0, /\{[^}]*\}/) + print substr($0, RSTART + 1, RLENGTH - 2) > attrs_file + in_block = 1 + first_line = 1 + next + } + in_block && /^```[[:space:]]*$/ { + in_block = 0 + exit + } + in_block { + if (first_line) { + printf "%s", $0 > content_file + first_line = 0 + } else { + printf "\n%s", $0 >> content_file + } + } + ' "${tmp_comment}" + + # Verify that a code block was found: + if [ ! -f "${tmp_attrs}" ]; then + post_source_comment "${quote} + +@${COMMENTER}, failed to parse the ${bt}/stdlib todo${bt} command. Expected a fenced code block with attributes, e.g., + +${bt}${bt}${bt} +/stdlib todo + +${bt}${bt}${bt}text {stdlib=public labels=\"foo,bar\"} +[TODO]: Issue title + +Issue body +${bt}${bt}${bt} +${bt}${bt}${bt}" + rm -rf "${tmp_dir}" + on_error 1 + fi + + attrs=$(cat "${tmp_attrs}") + content=$(cat "${tmp_content}" 2>/dev/null || printf '') + + # Parse the 'stdlib' attribute (values: 'public' or 'private'): + stdlib_target=$(printf '%s' "${attrs}" | grep -oiP '(?<=stdlib=)\w+' | tr '[:upper:]' '[:lower:]' || true) + if [ -z "${stdlib_target}" ]; then + stdlib_target="public" + fi + + if [ "${stdlib_target}" != "public" ] && [ "${stdlib_target}" != "private" ]; then + post_source_comment "${quote} + +@${COMMENTER}, unrecognized ${bt}stdlib${bt} attribute value ${bt}${stdlib_target}${bt}. Valid values are ${bt}public${bt} (opens issue on ${bt}stdlib-js/stdlib${bt}) or ${bt}private${bt} (opens issue on the internal todo repository)." + rm -rf "${tmp_dir}" + on_error 1 + fi + + # Parse the 'labels' attribute (comma-separated list): + labels_raw=$(printf '%s' "${attrs}" | grep -oP '(?<=labels=")[^"]*' || true) + if [ -n "${labels_raw}" ]; then + labels_json=$(printf '%s' "${labels_raw}" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -v '^$' | jq -R . | jq -s .) + else + labels_json='[]' + fi + + # Parse the issue title from the '[TODO]: ...' line: + first_line=$(printf '%s' "${content}" | head -n 1) + title=$(printf '%s' "${first_line}" | grep -ioP '(?<=^\[TODO\]:[[:space:]])\S.*' || true) + if [ -z "${title}" ]; then + post_source_comment "${quote} + +@${COMMENTER}, failed to parse the todo title. The first line of the code block must be of the form ${bt}[TODO]: Issue title${bt}." + rm -rf "${tmp_dir}" + on_error 1 + fi + + # Extract the issue body (lines after the title) and trim leading/trailing blank lines: + body_raw=$(printf '%s' "${content}" | tail -n +2) + body=$(printf '%s' "${body_raw}" | sed '/./,$!d' | tac | sed '/./,$!d' | tac) + + # Determine the target repository: + target_owner="stdlib-js" + if [ "${stdlib_target}" = "private" ]; then + target_repo="todo" + else + target_repo="stdlib" + fi + + # Verify the commenter is a member of the target organization: + membership_status=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "${github_api_url}/orgs/${target_owner}/members/${COMMENTER}") + + if [ "${membership_status}" != "204" ]; then + echo "Error checking org membership: HTTP ${membership_status}" >&2 + post_source_comment "${quote} + +@${COMMENTER}, you must be a member of the ${bt}${target_owner}${bt} organization to use the ${bt}/stdlib todo${bt} command." + rm -rf "${tmp_dir}" + on_error 1 + fi + + # Build provenance footer: + if [ "${SOURCE_TYPE}" = "pr" ]; then + provenance="--- +*Created via ${bt}/stdlib todo${bt} from [${repo_owner}/${repo_name}#${SOURCE_REF}](https://github.com/${repo_owner}/${repo_name}/pull/${SOURCE_REF}) by @${COMMENTER}.*" + else + provenance="--- +*Created via ${bt}/stdlib todo${bt} from [${repo_owner}/${repo_name}@${SOURCE_REF:0:7}](https://github.com/${repo_owner}/${repo_name}/commit/${SOURCE_REF}) by @${COMMENTER}.*" + fi + + # Build the issue body with provenance footer: + if [ -n "${body}" ]; then + issue_body="${body} + +${provenance}" + else + issue_body="${provenance}" + fi + + # Build issue creation JSON payload: + issue_payload=$(jq -n \ + --arg title "${title}" \ + --arg body "${issue_body}" \ + --argjson labels "${labels_json}" \ + '{"title": $title, "body": $body, "labels": $labels}') + + # Create the issue: + issue_response=$(curl -s \ + -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "Content-Type: application/json" \ + -d "${issue_payload}" \ + "${github_api_url}/repos/${target_owner}/${target_repo}/issues") + + issue_url=$(printf '%s' "${issue_response}" | jq -r '.html_url // empty') + if [ -z "${issue_url}" ]; then + echo "ERROR: Failed to create issue. Response: ${issue_response}" >&2 + rm -rf "${tmp_dir}" + on_error 1 + fi + + # Identify labels that were silently dropped (labels that do not exist in the target repo): + dropped_labels=() + while IFS= read -r requested_label; do + if [ -n "${requested_label}" ]; then + if ! printf '%s' "${issue_response}" | jq -e --arg l "${requested_label}" '[.labels[].name] | index($l) != null' > /dev/null 2>&1; then + dropped_labels+=("${requested_label}") + fi + fi + done < <(printf '%s' "${labels_json}" | jq -r '.[]') + + # Post a confirmation comment: + if [ ${#dropped_labels[@]} -gt 0 ]; then + dropped_list="" + for label in "${dropped_labels[@]}"; do + if [ -n "${dropped_list}" ]; then + dropped_list="${dropped_list}, ${bt}${label}${bt}" + else + dropped_list="${bt}${label}${bt}" + fi + done + post_source_comment "@${COMMENTER}, the following todo issue has been created: ${issue_url} + +> [!WARNING] +> The following labels were not applied because they do not exist on ${bt}${target_owner}/${target_repo}${bt}: ${dropped_list}." + else + post_source_comment "@${COMMENTER}, the following todo issue has been created: ${issue_url}" + fi + + rm -rf "${tmp_dir}" + print_success + exit 0 +} + +# Set an error handler to capture errors and perform any clean-up tasks: +trap 'on_error $?' ERR + +main diff --git a/.github/workflows/slash_commands.yml b/.github/workflows/slash_commands.yml index 5d27639d747b..1316ceee4683 100644 --- a/.github/workflows/slash_commands.yml +++ b/.github/workflows/slash_commands.yml @@ -25,6 +25,9 @@ on: types: - created - edited + commit_comment: + types: + - created # Workflow jobs: jobs: @@ -39,12 +42,13 @@ jobs: runs-on: ubuntu-latest # Define the conditions under which the job should run: - if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/stdlib') + if: (github.event.issue.pull_request || github.event_name == 'commit_comment') && startsWith(github.event.comment.body, '/stdlib') # Define the job's steps: steps: # Add "bot: In progress" label to the issue / PR: - name: 'Add in-progress label' + if: github.event.issue.pull_request # Pin action to full length commit SHA uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -63,28 +67,52 @@ jobs: with: github-token: ${{ secrets.STDLIB_BOT_PAT_REPO_WRITE }} script: | + const isCommitComment = context.eventName === 'commit_comment'; const commentBody = context.payload.comment.body.trim(); const RE_COMMANDS = /^\/stdlib\s+(help|check-files|update-copyright-years|lint-autofix|merge|rebase|make-commands)$/i; - const isRecognizedCommand = RE_COMMANDS.test( commentBody ); + const RE_TODO_COMMAND = /^\/stdlib\s+todo\b/i; + + // For commit comments, only the todo command is supported: + const isRecognizedCommand = isCommitComment + ? RE_TODO_COMMAND.test( commentBody ) + : RE_COMMANDS.test( commentBody ) || RE_TODO_COMMAND.test( commentBody ); if ( isRecognizedCommand ) { - await github.rest.reactions.createForIssueComment({ - 'owner': context.repo.owner, - 'repo': context.repo.repo, - 'comment_id': context.payload.comment.id, - 'content': 'eyes' - }); + if ( isCommitComment ) { + await github.rest.reactions.createForCommitComment({ + 'owner': context.repo.owner, + 'repo': context.repo.repo, + 'comment_id': context.payload.comment.id, + 'content': 'eyes' + }); + } else { + await github.rest.reactions.createForIssueComment({ + 'owner': context.repo.owner, + 'repo': context.repo.repo, + 'comment_id': context.payload.comment.id, + 'content': 'eyes' + }); + } } else { // Include the full user comment as a Markdown quote block in response: const lines = commentBody.split( '\n' ); const quote = lines.map( line => `> ${line}` ).join( '\n' ); - await github.rest.issues.createComment({ - 'owner': context.repo.owner, - 'repo': context.repo.repo, - 'issue_number': context.issue.number, - 'body': `${quote}\n\n@${context.payload.comment.user.login}, slash command not recognized. Please use \`/stdlib help\` to view available commands.` - }); + if ( isCommitComment ) { + await github.rest.repos.createCommitComment({ + 'owner': context.repo.owner, + 'repo': context.repo.repo, + 'commit_sha': context.payload.comment.commit_id, + 'body': `${quote}\n\n@${context.payload.comment.user.login}, slash command not recognized. On commit comments, only \`/stdlib todo\` is supported.` + }); + } else { + await github.rest.issues.createComment({ + 'owner': context.repo.owner, + 'repo': context.repo.repo, + 'issue_number': context.issue.number, + 'body': `${quote}\n\n@${context.payload.comment.user.login}, slash command not recognized. Please use \`/stdlib help\` to view available commands.` + }); + } } # Define a job for checking for required files: @@ -206,6 +234,28 @@ jobs: STDLIB_BOT_GPG_PRIVATE_KEY: ${{ secrets.STDLIB_BOT_GPG_PRIVATE_KEY }} STDLIB_BOT_GPG_PASSPHRASE: ${{ secrets.STDLIB_BOT_GPG_PASSPHRASE }} + # Define a job for creating a new todo issue: + todo: + + # Define a display name: + name: 'Create a new todo issue' + + # Ensure initial reaction job has completed before running this job: + needs: [ add_initial_reaction ] + + # Define the conditions under which the job should run: + if: (github.event.issue.pull_request || github.event_name == 'commit_comment') && startsWith(github.event.comment.body, '/stdlib todo') + + # Run reusable workflow: + uses: ./.github/workflows/create_todo_issue.yml + with: + source_type: ${{ github.event_name == 'commit_comment' && 'commit' || 'pr' }} + source_ref: ${{ github.event_name == 'commit_comment' && github.event.comment.commit_id || github.event.issue.number }} + comment_body: ${{ github.event.comment.body }} + user: ${{ github.event.comment.user.login }} + secrets: + STDLIB_BOT_GITHUB_TOKEN: ${{ secrets.STDLIB_BOT_PAT_REPO_WRITE }} + # Define a job for printing a list of available slash commands: help: @@ -241,6 +291,7 @@ jobs: - `/stdlib lint-autofix` - Auto-fix lint errors. - `/stdlib merge` - Merge changes from develop branch into this PR. - `/stdlib rebase` - Rebase this PR on top of develop branch. + - `/stdlib todo` - Create a new todo issue. # GitHub token: token: ${{ secrets.STDLIB_BOT_PAT_REPO_WRITE }} @@ -255,7 +306,7 @@ jobs: runs-on: ubuntu-latest # Ensure all previous jobs have completed before running this job: - needs: [ add_initial_reaction, check_files, make-commands, update_copyright_years, fix_lint_errors, merge_develop, rebase_develop, help ] + needs: [ add_initial_reaction, check_files, make-commands, update_copyright_years, fix_lint_errors, merge_develop, rebase_develop, help, todo ] # Define the conditions under which the job should run: if: |