diff --git a/.github/actions/bot-autoassign/issue_assignment_bot.py b/.github/actions/bot-autoassign/issue_assignment_bot.py index 9e807a8e..740537cd 100644 --- a/.github/actions/bot-autoassign/issue_assignment_bot.py +++ b/.github/actions/bot-autoassign/issue_assignment_bot.py @@ -273,7 +273,7 @@ def handle_issue_comment(self): try: if self.event_payload.get("issue", {}).get("pull_request"): print("Comment is on a PR, not an issue - skipping") - return False + return True comment = self.event_payload.get("comment", {}) issue = self.event_payload.get("issue", {}) comment_body = comment.get("body", "") @@ -285,7 +285,7 @@ def handle_issue_comment(self): if self.is_assignment_request(comment_body): return self.respond_to_assignment_request(issue_number, commenter) print("Comment does not contain assignment request") - return False + return True except Exception as e: print(f"Error handling issue comment: {e}") return False @@ -308,10 +308,13 @@ def handle_pull_request(self): # We consider the event handled even if no issues were linked return True elif action == "closed": - self.unassign_issues_from_pr(pr_body, pr_author) + if pr.get("merged", False): + print(f"PR #{pr_number} was merged, keeping issue assignments") + else: + self.unassign_issues_from_pr(pr_body, pr_author) return True print(f"PR action '{action}' not handled") - return False + return True except Exception as e: print(f"Error handling pull request: {e}") return False @@ -328,7 +331,7 @@ def run(self): return self.handle_pull_request() else: print(f"Event type '{self.event_name}'" " not supported") - return False + return True except Exception as e: print(f"Error in main execution: {e}") return False diff --git a/.github/actions/bot-autoassign/pr_reopen_bot.py b/.github/actions/bot-autoassign/pr_reopen_bot.py index bf2bfa34..c99a166e 100644 --- a/.github/actions/bot-autoassign/pr_reopen_bot.py +++ b/.github/actions/bot-autoassign/pr_reopen_bot.py @@ -76,7 +76,7 @@ def handle_pr_reopen(self): print(f"Reassigned {len(reassigned)}" f" issues to {pr_author}") return True except Exception as e: - print(f"Error handling PR reopen: {e}") + print(f"Error handling reopened PR: {e}") return False def run(self): @@ -89,7 +89,7 @@ def run(self): return self.handle_pr_reopen() else: print(f"Event type '{self.event_name}'" " not supported") - return False + return True except Exception as e: print(f"Error in main execution: {e}") return False @@ -113,15 +113,15 @@ def handle_contributor_activity(self): return False if not issue_data.get("pull_request"): print("Comment is on an issue," " not a PR, skipping") - return False + return True pr = self.repo.get_pull(pr_number) if not pr.user or commenter != pr.user.login: print("Comment not from PR author, skipping") - return False + return True labels = [label.name for label in pr.get_labels()] if "stale" not in labels: print("PR is not stale, skipping") - return False + return True try: pr.remove_from_labels("stale") print("Removed stale label") @@ -169,7 +169,7 @@ def run(self): return self.handle_contributor_activity() else: print(f"Event type '{self.event_name}'" " not supported") - return False + return True except Exception as e: print(f"Error in main execution: {e}") return False diff --git a/.github/actions/bot-autoassign/stale_pr_bot.py b/.github/actions/bot-autoassign/stale_pr_bot.py index 95cca167..dfecc7fd 100644 --- a/.github/actions/bot-autoassign/stale_pr_bot.py +++ b/.github/actions/bot-autoassign/stale_pr_bot.py @@ -144,7 +144,7 @@ def unassign_linked_issues(self, pr): def close_stale_pr(self, pr, days_inactive): if pr.state == "closed": print(f"PR #{pr.number} is already closed, skipping") - return False + return True try: pr_author = pr.user.login if pr.user else None if not pr_author: diff --git a/.github/actions/bot-autoassign/tests/test_issue_assignment_bot.py b/.github/actions/bot-autoassign/tests/test_issue_assignment_bot.py index 7a21e4ae..16e226f1 100644 --- a/.github/actions/bot-autoassign/tests/test_issue_assignment_bot.py +++ b/.github/actions/bot-autoassign/tests/test_issue_assignment_bot.py @@ -291,7 +291,7 @@ def test_skip_pr_comment(self, bot_env): }, } ) - assert not bot.handle_issue_comment() + assert bot.handle_issue_comment() def test_non_assignment_comment(self, bot_env): bot = IssueAssignmentBot() @@ -304,7 +304,7 @@ def test_non_assignment_comment(self, bot_env): }, } ) - assert not bot.handle_issue_comment() + assert bot.handle_issue_comment() def test_no_payload(self, bot_env): bot = IssueAssignmentBot() @@ -373,6 +373,22 @@ def test_closed(self, bot_env): assert bot.handle_pull_request() mock_issue.remove_from_assignees.assert_called_once_with("testuser") + def test_merged_does_not_unassign(self, bot_env): + bot = IssueAssignmentBot() + bot.load_event_payload( + { + "action": "closed", + "pull_request": { + "number": 100, + "user": {"login": "testuser"}, + "body": "Fixes #123", + "merged": True, + }, + } + ) + assert bot.handle_pull_request() + bot_env["repo"].get_issue.assert_not_called() + def test_unsupported_action(self, bot_env): bot = IssueAssignmentBot() bot.load_event_payload( @@ -385,7 +401,7 @@ def test_unsupported_action(self, bot_env): }, } ) - assert not bot.handle_pull_request() + assert bot.handle_pull_request() class TestRun: @@ -431,7 +447,7 @@ def test_pull_request_event(self, bot_env): def test_unsupported_event(self, bot_env): bot = IssueAssignmentBot() bot.event_name = "push" - assert not bot.run() + assert bot.run() def test_no_github_client(self, bot_env): bot = IssueAssignmentBot() diff --git a/.github/actions/bot-autoassign/tests/test_pr_reopen_bot.py b/.github/actions/bot-autoassign/tests/test_pr_reopen_bot.py index 04572cea..335ffd72 100644 --- a/.github/actions/bot-autoassign/tests/test_pr_reopen_bot.py +++ b/.github/actions/bot-autoassign/tests/test_pr_reopen_bot.py @@ -108,7 +108,7 @@ def test_handle_pr_reopen_no_payload(self, bot_env): def test_run_unsupported_event(self, bot_env): bot = PRReopenBot() bot.event_name = "push" - assert not bot.run() + assert bot.run() class TestPRActivityBot: @@ -160,7 +160,7 @@ def test_handle_contributor_activity_not_author(self, bot_env): mock_pr = Mock() mock_pr.user.login = "testuser" bot_env["repo"].get_pull.return_value = mock_pr - assert not bot.handle_contributor_activity() + assert bot.handle_contributor_activity() def test_handle_contributor_activity_pr_not_stale(self, bot_env): bot = PRActivityBot() @@ -177,7 +177,7 @@ def test_handle_contributor_activity_pr_not_stale(self, bot_env): mock_pr.user.login = "testuser" mock_pr.get_labels.return_value = [] bot_env["repo"].get_pull.return_value = mock_pr - assert not bot.handle_contributor_activity() + assert bot.handle_contributor_activity() def test_handle_contributor_activity_not_pr(self, bot_env): bot = PRActivityBot() @@ -190,7 +190,7 @@ def test_handle_contributor_activity_not_pr(self, bot_env): "comment": {"user": {"login": "testuser"}}, } ) - assert not bot.handle_contributor_activity() + assert bot.handle_contributor_activity() def test_handle_contributor_activity_no_payload(self, bot_env): bot = PRActivityBot() @@ -199,4 +199,4 @@ def test_handle_contributor_activity_no_payload(self, bot_env): def test_run_unsupported_event(self, bot_env): bot = PRActivityBot() bot.event_name = "push" - assert not bot.run() + assert bot.run() diff --git a/.github/actions/bot-autoassign/tests/test_stale_pr_bot.py b/.github/actions/bot-autoassign/tests/test_stale_pr_bot.py index 76fa6f49..20892a3a 100644 --- a/.github/actions/bot-autoassign/tests/test_stale_pr_bot.py +++ b/.github/actions/bot-autoassign/tests/test_stale_pr_bot.py @@ -231,7 +231,7 @@ def test_already_closed(self, bot_env): bot = StalePRBot() mock_pr = Mock() mock_pr.state = "closed" - assert not bot.close_stale_pr(mock_pr, 60) + assert bot.close_stale_pr(mock_pr, 60) mock_pr.create_issue_comment.assert_not_called() mock_pr.edit.assert_not_called() diff --git a/.github/workflows/bot-autoassign-pr-issue-link.yml b/.github/workflows/bot-autoassign-pr-issue-link.yml index 6f8ca8ae..d79c4218 100644 --- a/.github/workflows/bot-autoassign-pr-issue-link.yml +++ b/.github/workflows/bot-autoassign-pr-issue-link.yml @@ -15,6 +15,7 @@ concurrency: jobs: auto-assign-issue: + if: github.event.action != 'closed' || github.event.pull_request.merged == false runs-on: ubuntu-latest steps: - name: Generate GitHub App token diff --git a/.github/workflows/reusable-bot-autoassign.yml b/.github/workflows/reusable-bot-autoassign.yml new file mode 100644 index 00000000..922bee1c --- /dev/null +++ b/.github/workflows/reusable-bot-autoassign.yml @@ -0,0 +1,53 @@ +name: Reusable Bot Autoassign + +on: + workflow_call: + inputs: + bot_command: + required: true + type: string + description: "The bot to execute (e.g., 'issue_assignment', 'pr_reopen', 'stale_pr')" + secrets: + OPENWISP_BOT_APP_ID: + required: true + OPENWISP_BOT_PRIVATE_KEY: + required: true + +jobs: + run-bot: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.OPENWISP_BOT_APP_ID }} + private-key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + + - name: Checkout openwisp-utils + uses: actions/checkout@v6 + with: + repository: openwisp/openwisp-utils + ref: master + path: openwisp-utils + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -e 'openwisp-utils/.[github_actions]' + + - name: Execute bot script + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + BOT_COMMAND: ${{ inputs.bot_command }} + run: | + if [ -n "$GITHUB_EVENT_PATH" ]; then + python openwisp-utils/.github/actions/bot-autoassign/__main__.py "$BOT_COMMAND" "$GITHUB_EVENT_PATH" + else + python openwisp-utils/.github/actions/bot-autoassign/__main__.py "$BOT_COMMAND" + fi diff --git a/docs/developer/reusable-github-utils.rst b/docs/developer/reusable-github-utils.rst index a6aa5a61..79fb22f2 100644 --- a/docs/developer/reusable-github-utils.rst +++ b/docs/developer/reusable-github-utils.rst @@ -86,70 +86,137 @@ the following environment variables at runtime: ``GITHUB_TOKEN``, **Setup for Other Repositories** -To enable the auto-assignment bot in another OpenWISP repository, add -workflow files under ``.github/workflows/``. Each workflow needs to: +To enable the auto-assignment bot in another OpenWISP repository, you must +create four workflow files under ``.github/workflows/`` that call the +reusable GitHub Workflow. This reusable workflow automatically handles +token generation, environment setup, and executing the bot scripts. -1. Generate a GitHub App token using the OpenWISP Bot credentials. -2. Checkout ``openwisp-utils`` to get the bot scripts. -3. Install the bot dependencies via ``pip install -e .[github_actions]``. -4. Run the appropriate bot command. +.. note:: + + Each caller workflow must declare its own ``permissions`` block. + GitHub Actions reusable workflows inherit permissions from the caller, + so the reusable workflow cannot set them on its own. + +Create the following workflow files in your repository. -Below is a complete example for the issue assignment bot. You can find all -four workflow files in the ``openwisp-utils`` repository under -``.github/workflows/`` (``bot-autoassign-issue.yml``, -``bot-autoassign-pr-issue-link.yml``, ``bot-autoassign-pr-reopen.yml``, -``bot-autoassign-stale-pr.yml``). +**1. Issue Assignment Bot** +(``.github/workflows/bot-autoassign-issue.yml``) .. code-block:: yaml name: Issue Assignment Bot - on: issue_comment: types: [created] - permissions: contents: read issues: write - concurrency: group: bot-autoassign-issue-${{ github.repository }}-${{ github.event.issue.number }} cancel-in-progress: true - jobs: respond-to-assign-request: - runs-on: ubuntu-latest if: github.event.issue.pull_request == null - steps: - - name: Generate GitHub App token - id: generate-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.OPENWISP_BOT_APP_ID }} - private-key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master + with: + bot_command: issue_assignment + secrets: + OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} + OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} - - name: Checkout openwisp-utils - uses: actions/checkout@v6 - with: - repository: openwisp/openwisp-utils - path: openwisp-utils +**2. PR Issue Link** +(``.github/workflows/bot-autoassign-pr-issue-link.yml``) - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" +.. code-block:: yaml - - name: Install dependencies - run: pip install -e openwisp-utils/.[github_actions] + name: PR Issue Auto-Assignment + on: + pull_request_target: + types: [opened, reopened, closed] + permissions: + contents: read + issues: write + pull-requests: read + concurrency: + group: bot-autoassign-pr-link-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: + auto-assign-issue: + if: github.event.action != 'closed' || github.event.pull_request.merged == false + uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master + with: + bot_command: issue_assignment + secrets: + OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} + OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} - - name: Run issue assignment bot - env: - GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - REPOSITORY: ${{ github.repository }} - GITHUB_EVENT_NAME: ${{ github.event_name }} - run: > - python openwisp-utils/.github/actions/bot-autoassign/__main__.py - issue_assignment "$GITHUB_EVENT_PATH" +**3. PR Reopen** (``.github/workflows/bot-autoassign-pr-reopen.yml``) + +.. code-block:: yaml + + name: PR Reopen Reassignment + on: + pull_request_target: + types: [reopened] + issue_comment: + types: [created] + permissions: + contents: read + issues: write + pull-requests: write + concurrency: + group: bot-autoassign-pr-reopen-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + jobs: + reassign-on-reopen: + if: github.event_name == 'pull_request_target' && github.event.action == 'reopened' + uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master + with: + bot_command: pr_reopen + secrets: + OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} + OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + handle-pr-activity: + if: github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login + uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master + with: + bot_command: pr_reopen + secrets: + OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} + OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + +.. note:: + + Both jobs use ``bot_command: pr_reopen``. The ``pr_reopen`` command + dispatches to ``PRReopenBot`` on ``pull_request_target`` events (to + reassign issues when a PR is reopened) and to ``PRActivityBot`` on + ``issue_comment`` events (to remove the stale label when the PR author + comments on their stale PR). + +**4. Stale PR** (``.github/workflows/bot-autoassign-stale-pr.yml``) + +.. code-block:: yaml + + name: Stale PR Management + on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + permissions: + contents: read + issues: write + pull-requests: write + concurrency: + group: bot-autoassign-stale-pr-${{ github.repository }} + cancel-in-progress: false + jobs: + manage-stale-prs-python: + uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master + with: + bot_command: stale_pr + secrets: + OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} + OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} GitHub Workflows ----------------