ci: add bot @floatpanebot (#746)

Drew Smirnoff created

Change summary

.github/workflows/bot-approve.yml   |  56 ++++++++++++++++
.github/workflows/bot-check-ci.yml  | 104 +++++++++++++++++++++++++++++++
.github/workflows/bot-pr-checks.yml |  84 +++++++++++++++++++++++++
.github/workflows/demo.yml          |   4 
.github/workflows/screenshots.yml   |   4 
.github/workflows/update-flake.yml  |   6 
6 files changed, 251 insertions(+), 7 deletions(-)

Detailed changes

.github/workflows/bot-approve.yml 🔗

@@ -0,0 +1,56 @@
+name: Bot - PR Approval Command
+
+on:
+  issue_comment:
+    types: [created]
+
+jobs:
+  approve:
+    if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/approve') }}
+    runs-on: ubuntu-latest
+    steps:
+      - name: Process Approval Command
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          script: |
+            const commenter = context.payload.comment.user.login;
+            const owner = context.repo.owner;
+            const repo = context.repo.repo;
+            const pr_number = context.issue.number;
+
+            try {
+              // 1. Verify membership in the floatpane organization
+              await github.rest.orgs.checkMembershipForUser({
+                org: 'floatpane',
+                username: commenter,
+              });
+
+              // 2. Add an approving review
+              await github.rest.pulls.createReview({
+                owner,
+                repo,
+                pull_number: pr_number,
+                event: 'APPROVE',
+                body: `Approved on behalf of @${commenter} via \`/approve\` command.`
+              });
+
+              // Optionally add a reaction to the command comment to acknowledge it
+              await github.rest.reactions.createForIssueComment({
+                owner,
+                repo,
+                comment_id: context.payload.comment.id,
+                content: 'rocket'
+              });
+
+            } catch (error) {
+              if (error.status === 404) {
+                // Not a member
+                await github.rest.issues.createComment({
+                  owner, repo, issue_number: pr_number,
+                  body: `Sorry @${commenter}, only members of the \`floatpane\` organization can use the \`/approve\` command.`
+                });
+              } else {
+                core.setFailed(`Error processing approval: ${error.message}`);
+              }
+            }

.github/workflows/bot-check-ci.yml 🔗

@@ -0,0 +1,104 @@
+name: Bot - CI Failure Notifier
+
+on:
+  workflow_run:
+    workflows: ["CI"]
+    types: [completed]
+
+jobs:
+  notify:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Process CI Result
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          script: |
+            const run = context.payload.workflow_run;
+            const owner = context.repo.owner;
+            const repo = context.repo.repo;
+
+            // 1. Robustly find the PR number (works for forks too)
+            let pr_number;
+            if (run.pull_requests && run.pull_requests.length > 0) {
+              pr_number = run.pull_requests[0].number;
+            } else {
+              // Fallback: Find PR associated with the commit SHA
+              const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+                owner,
+                repo,
+                commit_sha: run.head_sha
+              });
+              if (prs.length > 0) {
+                pr_number = prs[0].number;
+              }
+            }
+
+            if (!pr_number) {
+              console.log("No open PR found for this workflow run. Exiting.");
+              return;
+            }
+
+            // 2. Handle CI Failure -> Request Changes
+            if (run.conclusion === 'failure') {
+              const jobs = await github.rest.actions.listJobsForWorkflowRun({
+                owner, repo, run_id: run.id
+              });
+
+              const failedJobs = jobs.data.jobs.filter(j => j.conclusion === 'failure').map(j => j.name);
+
+              let message = "The CI workflow failed. Please fix the following issues locally and push again:\n\n";
+              let foundSpecificInstruction = false;
+
+              if (failedJobs.some(name => name.includes('lint'))) {
+                message += "- **Lint Failed**: Run `gofmt -w .` to format files and `go vet ./...` to check for vet errors.\n";
+                foundSpecificInstruction = true;
+              }
+              if (failedJobs.some(name => name.includes('mod-tidy'))) {
+                message += "- **Mod-tidy Failed**: Run `go mod tidy` locally and commit the resulting `go.mod` and `go.sum` changes.\n";
+                foundSpecificInstruction = true;
+              }
+              if (failedJobs.some(name => name.includes('nix'))) {
+                message += "- **Nix Failed**: Run `nix flake check --no-build` locally to find the issue with the flake.\n";
+                foundSpecificInstruction = true;
+              }
+
+              if (!foundSpecificInstruction) {
+                message += "Please check the [CI logs](" + run.html_url + ") for more details on the failure.";
+              }
+
+              // Submit an official "Request Changes" review
+              await github.rest.pulls.createReview({
+                owner,
+                repo,
+                pull_number: pr_number,
+                body: message,
+                event: 'REQUEST_CHANGES'
+              });
+
+            }
+            // 3. Handle CI Success -> Dismiss Reviews
+            else if (run.conclusion === 'success') {
+              const { data: botUser } = await github.rest.users.getAuthenticated();
+
+              const { data: reviews } = await github.rest.pulls.listReviews({
+                owner, repo, pull_number: pr_number
+              });
+
+              // Find active blocking reviews left by the bot account
+              const botReviews = reviews.filter(r =>
+                r.user.login === botUser.login &&
+                r.state === 'CHANGES_REQUESTED'
+              );
+
+              // Dismiss them
+              for (const review of botReviews) {
+                await github.rest.pulls.dismissReview({
+                  owner,
+                  repo,
+                  pull_number: pr_number,
+                  review_id: review.id,
+                  message: 'The CI workflow has passed! Dismissing previous review.'
+                });
+              }
+            }

.github/workflows/bot-pr-checks.yml 🔗

@@ -0,0 +1,84 @@
+name: Bot - PR Formatting Checks
+
+on:
+  pull_request_target:
+    types: [opened, edited, synchronize]
+
+jobs:
+  check-pr:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Validate PR Title and Body
+        uses: actions/github-script@v7
+        env:
+          PR_BODY: ${{ github.event.pull_request.body }}
+          PR_TITLE: ${{ github.event.pull_request.title }}
+        with:
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          script: |
+            const title = process.env.PR_TITLE;
+            const body = process.env.PR_BODY || "";
+            const owner = context.repo.owner;
+            const repo = context.repo.repo;
+            const pull_number = context.issue.number;
+
+            let errors = [];
+
+            // 1. Check Conventional Commits
+            const ccRegex = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9\-]+\))?:\s.+$/;
+            if (!ccRegex.test(title)) {
+              errors.push("- **Title**: Does not follow conventional commits (e.g., `feat: added something`, `fix(core): resolved crash`).");
+            }
+
+            // 2. Check PR Template adherence
+            if (!body.includes("## What?") || !body.includes("## Why?")) {
+              errors.push("- **Body**: Missing the `## What?` or `## Why?` headings required by the PR template.");
+            }
+
+            // Check if they just left the HTML comments from the template empty
+            const cleanedBody = body.replace(//g, "").trim();
+            if (cleanedBody.length < 10) {
+              errors.push("- **Body**: The PR description is too short or hasn't replaced the template placeholders.");
+            }
+
+            // 3. Request Changes or Dismiss previous requests
+            if (errors.length > 0) {
+              const message = `Hi @${context.actor}! Please fix the following issues with your PR:\n\n${errors.join("\n")}`;
+
+              await github.rest.pulls.createReview({
+                owner,
+                repo,
+                pull_number,
+                body: message,
+                event: 'REQUEST_CHANGES'
+              });
+
+              core.setFailed("PR formatting checks failed.");
+            } else {
+              // The PR is now valid. Let's find out who the bot is to dismiss its own reviews.
+              const { data: botUser } = await github.rest.users.getAuthenticated();
+
+              // Fetch all reviews on this PR
+              const { data: reviews } = await github.rest.pulls.listReviews({
+                owner,
+                repo,
+                pull_number
+              });
+
+              // Find active blocking reviews left by the bot account
+              const botReviews = reviews.filter(r =>
+                r.user.login === botUser.login &&
+                r.state === 'CHANGES_REQUESTED'
+              );
+
+              // Dismiss them so the PR is unblocked
+              for (const review of botReviews) {
+                await github.rest.pulls.dismissReview({
+                  owner,
+                  repo,
+                  pull_number,
+                  review_id: review.id,
+                  message: 'Formatting issues have been resolved. Thank you!'
+                });
+              }
+            }

.github/workflows/demo.yml 🔗

@@ -83,8 +83,8 @@ jobs:
       - name: Create Pull Request
         uses: peter-evans/create-pull-request@v8
         with:
-          token: ${{ secrets.GITHUB_TOKEN }}
-          commit-message: "docs: update demo.gif from release [skip ci]"
+          token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          commit-message: "docs: update demo.gif from release"
           title: "docs: update demo.gif"
           body: |
             This PR updates the demo GIF based on the latest release version.

.github/workflows/screenshots.yml 🔗

@@ -124,8 +124,8 @@ jobs:
       - name: Create Pull Request
         uses: peter-evans/create-pull-request@v8
         with:
-          token: ${{ secrets.GITHUB_TOKEN }}
-          commit-message: "docs: update feature screenshots [skip ci]"
+          token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          commit-message: "docs: update feature screenshots"
           title: "docs: update feature screenshots"
           body: |
             This PR updates the feature screenshots based on the latest release version.

.github/workflows/update-flake.yml 🔗

@@ -23,12 +23,12 @@ jobs:
 
       - name: Create Pull Request
         env:
-          GH_TOKEN: ${{ github.token }}
+          GH_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
         run: |
           git diff --quiet flake.lock && exit 0
           BRANCH="chore/update-flake-lock"
-          git config user.name "github-actions[bot]"
-          git config user.email "github-actions[bot]@users.noreply.github.com"
+          git config user.name "Floatpane Bot"
+          git config user.email "us@floatpane.com"
           git checkout -b "$BRANCH"
           git add flake.lock
           git commit -m "chore: update flake.lock"